SimpleBasePlayer.java

/*
 * Copyright 2022 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.common;

import static androidx.annotation.VisibleForTesting.PROTECTED;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Util.castNonNull;
import static androidx.media3.common.util.Util.msToUs;
import static androidx.media3.common.util.Util.usToMs;
import static java.lang.Math.max;
import static java.lang.Math.min;

import android.graphics.Rect;
import android.os.Looper;
import android.os.SystemClock;
import android.util.Pair;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.TextureView;
import androidx.annotation.FloatRange;
import androidx.annotation.IntRange;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.text.CueGroup;
import androidx.media3.common.util.Clock;
import androidx.media3.common.util.HandlerWrapper;
import androidx.media3.common.util.ListenerSet;
import androidx.media3.common.util.Size;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.common.base.Supplier;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.ForOverride;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;

/**
 * A base implementation for {@link Player} that reduces the number of methods to implement to a
 * minimum.
 *
 * <p>Implementation notes:
 *
 * <ul>
 *   <li>Subclasses must override {@link #getState()} to populate the current player state on
 *       request.
 *   <li>The {@link State} should set the {@linkplain State.Builder#setAvailableCommands available
 *       commands} to indicate which {@link Player} methods are supported.
 *   <li>All setter-like player methods (for example, {@link #setPlayWhenReady}) forward to
 *       overridable methods (for example, {@link #handleSetPlayWhenReady}) that can be used to
 *       handle these requests. These methods return a {@link ListenableFuture} to indicate when the
 *       request has been handled and is fully reflected in the values returned from {@link
 *       #getState}. This class will automatically request a state update once the request is done.
 *       If the state changes can be handled synchronously, these methods can return Guava's {@link
 *       Futures#immediateVoidFuture()}.
 *   <li>Subclasses can manually trigger state updates with {@link #invalidateState}, for example if
 *       something changes independent of {@link Player} method calls.
 * </ul>
 *
 * This base class handles various aspects of the player implementation to simplify the subclass:
 *
 * <ul>
 *   <li>The {@link State} can only be created with allowed combinations of state values, avoiding
 *       any invalid player states.
 *   <li>Only functionality that is declared as {@linkplain Player.Command available} needs to be
 *       implemented. Other methods are automatically ignored.
 *   <li>Listener handling and informing listeners of state changes is handled automatically.
 *   <li>The base class provides a framework for asynchronous handling of method calls. It changes
 *       the visible playback state immediately to the most likely outcome to ensure the
 *       user-visible state changes look like synchronous operations. The state is then updated
 *       again once the asynchronous method calls have been fully handled.
 * </ul>
 */
@UnstableApi
public abstract class SimpleBasePlayer extends BasePlayer {

  /** An immutable state description of the player. */
  protected static final class State {

    /** A builder for {@link State} objects. */
    public static final class Builder {

      private Commands availableCommands;
      private boolean playWhenReady;
      private @PlayWhenReadyChangeReason int playWhenReadyChangeReason;
      private @Player.State int playbackState;
      private @PlaybackSuppressionReason int playbackSuppressionReason;
      @Nullable private PlaybackException playerError;
      private @RepeatMode int repeatMode;
      private boolean shuffleModeEnabled;
      private boolean isLoading;
      private long seekBackIncrementMs;
      private long seekForwardIncrementMs;
      private long maxSeekToPreviousPositionMs;
      private PlaybackParameters playbackParameters;
      private TrackSelectionParameters trackSelectionParameters;
      private AudioAttributes audioAttributes;
      private float volume;
      private VideoSize videoSize;
      private CueGroup currentCues;
      private DeviceInfo deviceInfo;
      private int deviceVolume;
      private boolean isDeviceMuted;
      private Size surfaceSize;
      private boolean newlyRenderedFirstFrame;
      private Metadata timedMetadata;
      private ImmutableList<MediaItemData> playlist;
      private Timeline timeline;
      private MediaMetadata playlistMetadata;
      private int currentMediaItemIndex;
      private int currentAdGroupIndex;
      private int currentAdIndexInAdGroup;
      @Nullable private Long contentPositionMs;
      private PositionSupplier contentPositionMsSupplier;
      @Nullable private Long adPositionMs;
      private PositionSupplier adPositionMsSupplier;
      private PositionSupplier contentBufferedPositionMsSupplier;
      private PositionSupplier adBufferedPositionMsSupplier;
      private PositionSupplier totalBufferedDurationMsSupplier;
      private boolean hasPositionDiscontinuity;
      private @Player.DiscontinuityReason int positionDiscontinuityReason;
      private long discontinuityPositionMs;

      /** Creates the builder. */
      public Builder() {
        availableCommands = Commands.EMPTY;
        playWhenReady = false;
        playWhenReadyChangeReason = Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST;
        playbackState = Player.STATE_IDLE;
        playbackSuppressionReason = Player.PLAYBACK_SUPPRESSION_REASON_NONE;
        playerError = null;
        repeatMode = Player.REPEAT_MODE_OFF;
        shuffleModeEnabled = false;
        isLoading = false;
        seekBackIncrementMs = C.DEFAULT_SEEK_BACK_INCREMENT_MS;
        seekForwardIncrementMs = C.DEFAULT_SEEK_FORWARD_INCREMENT_MS;
        maxSeekToPreviousPositionMs = C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS;
        playbackParameters = PlaybackParameters.DEFAULT;
        trackSelectionParameters = TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT;
        audioAttributes = AudioAttributes.DEFAULT;
        volume = 1f;
        videoSize = VideoSize.UNKNOWN;
        currentCues = CueGroup.EMPTY_TIME_ZERO;
        deviceInfo = DeviceInfo.UNKNOWN;
        deviceVolume = 0;
        isDeviceMuted = false;
        surfaceSize = Size.UNKNOWN;
        newlyRenderedFirstFrame = false;
        timedMetadata = new Metadata(/* presentationTimeUs= */ C.TIME_UNSET);
        playlist = ImmutableList.of();
        timeline = Timeline.EMPTY;
        playlistMetadata = MediaMetadata.EMPTY;
        currentMediaItemIndex = C.INDEX_UNSET;
        currentAdGroupIndex = C.INDEX_UNSET;
        currentAdIndexInAdGroup = C.INDEX_UNSET;
        contentPositionMs = null;
        contentPositionMsSupplier = PositionSupplier.getConstant(C.TIME_UNSET);
        adPositionMs = null;
        adPositionMsSupplier = PositionSupplier.ZERO;
        contentBufferedPositionMsSupplier = PositionSupplier.getConstant(C.TIME_UNSET);
        adBufferedPositionMsSupplier = PositionSupplier.ZERO;
        totalBufferedDurationMsSupplier = PositionSupplier.ZERO;
        hasPositionDiscontinuity = false;
        positionDiscontinuityReason = Player.DISCONTINUITY_REASON_INTERNAL;
        discontinuityPositionMs = 0;
      }

      private Builder(State state) {
        this.availableCommands = state.availableCommands;
        this.playWhenReady = state.playWhenReady;
        this.playWhenReadyChangeReason = state.playWhenReadyChangeReason;
        this.playbackState = state.playbackState;
        this.playbackSuppressionReason = state.playbackSuppressionReason;
        this.playerError = state.playerError;
        this.repeatMode = state.repeatMode;
        this.shuffleModeEnabled = state.shuffleModeEnabled;
        this.isLoading = state.isLoading;
        this.seekBackIncrementMs = state.seekBackIncrementMs;
        this.seekForwardIncrementMs = state.seekForwardIncrementMs;
        this.maxSeekToPreviousPositionMs = state.maxSeekToPreviousPositionMs;
        this.playbackParameters = state.playbackParameters;
        this.trackSelectionParameters = state.trackSelectionParameters;
        this.audioAttributes = state.audioAttributes;
        this.volume = state.volume;
        this.videoSize = state.videoSize;
        this.currentCues = state.currentCues;
        this.deviceInfo = state.deviceInfo;
        this.deviceVolume = state.deviceVolume;
        this.isDeviceMuted = state.isDeviceMuted;
        this.surfaceSize = state.surfaceSize;
        this.newlyRenderedFirstFrame = state.newlyRenderedFirstFrame;
        this.timedMetadata = state.timedMetadata;
        this.playlist = state.playlist;
        this.timeline = state.timeline;
        this.playlistMetadata = state.playlistMetadata;
        this.currentMediaItemIndex = state.currentMediaItemIndex;
        this.currentAdGroupIndex = state.currentAdGroupIndex;
        this.currentAdIndexInAdGroup = state.currentAdIndexInAdGroup;
        this.contentPositionMs = null;
        this.contentPositionMsSupplier = state.contentPositionMsSupplier;
        this.adPositionMs = null;
        this.adPositionMsSupplier = state.adPositionMsSupplier;
        this.contentBufferedPositionMsSupplier = state.contentBufferedPositionMsSupplier;
        this.adBufferedPositionMsSupplier = state.adBufferedPositionMsSupplier;
        this.totalBufferedDurationMsSupplier = state.totalBufferedDurationMsSupplier;
        this.hasPositionDiscontinuity = state.hasPositionDiscontinuity;
        this.positionDiscontinuityReason = state.positionDiscontinuityReason;
        this.discontinuityPositionMs = state.discontinuityPositionMs;
      }

      /**
       * Sets the available {@link Commands}.
       *
       * @param availableCommands The available {@link Commands}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setAvailableCommands(Commands availableCommands) {
        this.availableCommands = availableCommands;
        return this;
      }

      /**
       * Sets whether playback should proceed when ready and not suppressed.
       *
       * @param playWhenReady Whether playback should proceed when ready and not suppressed.
       * @param playWhenReadyChangeReason The {@linkplain PlayWhenReadyChangeReason reason} for
       *     changing the value.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPlayWhenReady(
          boolean playWhenReady, @PlayWhenReadyChangeReason int playWhenReadyChangeReason) {
        this.playWhenReady = playWhenReady;
        this.playWhenReadyChangeReason = playWhenReadyChangeReason;
        return this;
      }

      /**
       * Sets the {@linkplain Player.State state} of the player.
       *
       * <p>If the {@linkplain #setPlaylist playlist} is empty, the state must be either {@link
       * Player#STATE_IDLE} or {@link Player#STATE_ENDED}.
       *
       * @param playbackState The {@linkplain Player.State state} of the player.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPlaybackState(@Player.State int playbackState) {
        this.playbackState = playbackState;
        return this;
      }

      /**
       * Sets the reason why playback is suppressed even if {@link #getPlayWhenReady()} is true.
       *
       * @param playbackSuppressionReason The {@link Player.PlaybackSuppressionReason} why playback
       *     is suppressed even if {@link #getPlayWhenReady()} is true.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPlaybackSuppressionReason(
          @Player.PlaybackSuppressionReason int playbackSuppressionReason) {
        this.playbackSuppressionReason = playbackSuppressionReason;
        return this;
      }

      /**
       * Sets last error that caused playback to fail, or null if there was no error.
       *
       * <p>The {@linkplain #setPlaybackState playback state} must be set to {@link
       * Player#STATE_IDLE} while an error is set.
       *
       * @param playerError The last error that caused playback to fail, or null if there was no
       *     error.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPlayerError(@Nullable PlaybackException playerError) {
        this.playerError = playerError;
        return this;
      }

      /**
       * Sets the {@link RepeatMode} used for playback.
       *
       * @param repeatMode The {@link RepeatMode} used for playback.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setRepeatMode(@Player.RepeatMode int repeatMode) {
        this.repeatMode = repeatMode;
        return this;
      }

      /**
       * Sets whether shuffling of media items is enabled.
       *
       * @param shuffleModeEnabled Whether shuffling of media items is enabled.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setShuffleModeEnabled(boolean shuffleModeEnabled) {
        this.shuffleModeEnabled = shuffleModeEnabled;
        return this;
      }

      /**
       * Sets whether the player is currently loading its source.
       *
       * <p>The player can not be marked as loading if the {@linkplain #setPlaybackState state} is
       * {@link Player#STATE_IDLE} or {@link Player#STATE_ENDED}.
       *
       * @param isLoading Whether the player is currently loading its source.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setIsLoading(boolean isLoading) {
        this.isLoading = isLoading;
        return this;
      }

      /**
       * Sets the {@link Player#seekBack()} increment in milliseconds.
       *
       * @param seekBackIncrementMs The {@link Player#seekBack()} increment in milliseconds.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setSeekBackIncrementMs(long seekBackIncrementMs) {
        this.seekBackIncrementMs = seekBackIncrementMs;
        return this;
      }

      /**
       * Sets the {@link Player#seekForward()} increment in milliseconds.
       *
       * @param seekForwardIncrementMs The {@link Player#seekForward()} increment in milliseconds.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setSeekForwardIncrementMs(long seekForwardIncrementMs) {
        this.seekForwardIncrementMs = seekForwardIncrementMs;
        return this;
      }

      /**
       * Sets the maximum position for which {@link #seekToPrevious()} seeks to the previous item,
       * in milliseconds.
       *
       * @param maxSeekToPreviousPositionMs The maximum position for which {@link #seekToPrevious()}
       *     seeks to the previous item, in milliseconds.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setMaxSeekToPreviousPositionMs(long maxSeekToPreviousPositionMs) {
        this.maxSeekToPreviousPositionMs = maxSeekToPreviousPositionMs;
        return this;
      }

      /**
       * Sets the currently active {@link PlaybackParameters}.
       *
       * @param playbackParameters The currently active {@link PlaybackParameters}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPlaybackParameters(PlaybackParameters playbackParameters) {
        this.playbackParameters = playbackParameters;
        return this;
      }

      /**
       * Sets the currently active {@link TrackSelectionParameters}.
       *
       * @param trackSelectionParameters The currently active {@link TrackSelectionParameters}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setTrackSelectionParameters(
          TrackSelectionParameters trackSelectionParameters) {
        this.trackSelectionParameters = trackSelectionParameters;
        return this;
      }

      /**
       * Sets the current {@link AudioAttributes}.
       *
       * @param audioAttributes The current {@link AudioAttributes}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setAudioAttributes(AudioAttributes audioAttributes) {
        this.audioAttributes = audioAttributes;
        return this;
      }

      /**
       * Sets the current audio volume, with 0 being silence and 1 being unity gain (signal
       * unchanged).
       *
       * @param volume The current audio volume, with 0 being silence and 1 being unity gain (signal
       *     unchanged).
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setVolume(@FloatRange(from = 0, to = 1.0) float volume) {
        checkArgument(volume >= 0.0f && volume <= 1.0f);
        this.volume = volume;
        return this;
      }

      /**
       * Sets the current video size.
       *
       * @param videoSize The current video size.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setVideoSize(VideoSize videoSize) {
        this.videoSize = videoSize;
        return this;
      }

      /**
       * Sets the current {@linkplain CueGroup cues}.
       *
       * @param currentCues The current {@linkplain CueGroup cues}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setCurrentCues(CueGroup currentCues) {
        this.currentCues = currentCues;
        return this;
      }

      /**
       * Sets the {@link DeviceInfo}.
       *
       * @param deviceInfo The {@link DeviceInfo}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setDeviceInfo(DeviceInfo deviceInfo) {
        this.deviceInfo = deviceInfo;
        return this;
      }

      /**
       * Sets the current device volume.
       *
       * @param deviceVolume The current device volume.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setDeviceVolume(@IntRange(from = 0) int deviceVolume) {
        checkArgument(deviceVolume >= 0);
        this.deviceVolume = deviceVolume;
        return this;
      }

      /**
       * Sets whether the device is muted.
       *
       * @param isDeviceMuted Whether the device is muted.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setIsDeviceMuted(boolean isDeviceMuted) {
        this.isDeviceMuted = isDeviceMuted;
        return this;
      }

      /**
       * Sets the size of the surface onto which the video is being rendered.
       *
       * @param surfaceSize The surface size. Dimensions may be {@link C#LENGTH_UNSET} if unknown,
       *     or 0 if the video is not rendered onto a surface.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setSurfaceSize(Size surfaceSize) {
        this.surfaceSize = surfaceSize;
        return this;
      }

      /**
       * Sets whether a frame has been rendered for the first time since setting the surface, a
       * rendering reset, or since the stream being rendered was changed.
       *
       * <p>Note: As this will trigger a {@link Listener#onRenderedFirstFrame()} event, the flag
       * should only be set for the first {@link State} update after the first frame was rendered.
       *
       * @param newlyRenderedFirstFrame Whether the first frame was newly rendered.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setNewlyRenderedFirstFrame(boolean newlyRenderedFirstFrame) {
        this.newlyRenderedFirstFrame = newlyRenderedFirstFrame;
        return this;
      }

      /**
       * Sets the most recent timed {@link Metadata}.
       *
       * <p>Metadata with a {@link Metadata#presentationTimeUs} of {@link C#TIME_UNSET} will not be
       * forwarded to listeners.
       *
       * @param timedMetadata The most recent timed {@link Metadata}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setTimedMetadata(Metadata timedMetadata) {
        this.timedMetadata = timedMetadata;
        return this;
      }

      /**
       * Sets the list of {@link MediaItemData media items} in the playlist.
       *
       * <p>All items must have unique {@linkplain MediaItemData.Builder#setUid UIDs}.
       *
       * @param playlist The list of {@link MediaItemData media items} in the playlist.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPlaylist(List<MediaItemData> playlist) {
        HashSet<Object> uids = new HashSet<>();
        for (int i = 0; i < playlist.size(); i++) {
          checkArgument(uids.add(playlist.get(i).uid), "Duplicate MediaItemData UID in playlist");
        }
        this.playlist = ImmutableList.copyOf(playlist);
        this.timeline = new PlaylistTimeline(this.playlist);
        return this;
      }

      /**
       * Sets the playlist {@link MediaMetadata}.
       *
       * @param playlistMetadata The playlist {@link MediaMetadata}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPlaylistMetadata(MediaMetadata playlistMetadata) {
        this.playlistMetadata = playlistMetadata;
        return this;
      }

      /**
       * Sets the current media item index.
       *
       * <p>The media item index must be less than the number of {@linkplain #setPlaylist media
       * items in the playlist}, if set.
       *
       * @param currentMediaItemIndex The current media item index, or {@link C#INDEX_UNSET} to
       *     assume the default first item in the playlist.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setCurrentMediaItemIndex(int currentMediaItemIndex) {
        this.currentMediaItemIndex = currentMediaItemIndex;
        return this;
      }

      /**
       * Sets the current ad indices, or {@link C#INDEX_UNSET} if no ad is playing.
       *
       * <p>Either both indices need to be {@link C#INDEX_UNSET} or both are not {@link
       * C#INDEX_UNSET}.
       *
       * <p>Ads indices can only be set if there is a corresponding {@link AdPlaybackState} defined
       * in the current {@linkplain MediaItemData.Builder#setPeriods period}.
       *
       * @param adGroupIndex The current ad group index, or {@link C#INDEX_UNSET} if no ad is
       *     playing.
       * @param adIndexInAdGroup The current ad index in the ad group, or {@link C#INDEX_UNSET} if
       *     no ad is playing.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setCurrentAd(int adGroupIndex, int adIndexInAdGroup) {
        checkArgument((adGroupIndex == C.INDEX_UNSET) == (adIndexInAdGroup == C.INDEX_UNSET));
        this.currentAdGroupIndex = adGroupIndex;
        this.currentAdIndexInAdGroup = adIndexInAdGroup;
        return this;
      }

      /**
       * Sets the current content playback position in milliseconds.
       *
       * <p>This position will be converted to an advancing {@link PositionSupplier} if the overall
       * state indicates an advancing playback position.
       *
       * <p>This method overrides any other {@link PositionSupplier} set via {@link
       * #setContentPositionMs(PositionSupplier)}.
       *
       * @param positionMs The current content playback position in milliseconds, or {@link
       *     C#TIME_UNSET} to indicate the default start position.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setContentPositionMs(long positionMs) {
        this.contentPositionMs = positionMs;
        return this;
      }

      /**
       * Sets the {@link PositionSupplier} for the current content playback position in
       * milliseconds.
       *
       * <p>The supplier is expected to return the updated position on every call if the playback is
       * advancing, for example by using {@link PositionSupplier#getExtrapolating}.
       *
       * <p>This method overrides any other position set via {@link #setContentPositionMs(long)}.
       *
       * @param contentPositionMsSupplier The {@link PositionSupplier} for the current content
       *     playback position in milliseconds, or {@link C#TIME_UNSET} to indicate the default
       *     start position.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setContentPositionMs(PositionSupplier contentPositionMsSupplier) {
        this.contentPositionMs = null;
        this.contentPositionMsSupplier = contentPositionMsSupplier;
        return this;
      }

      /**
       * Sets the current ad playback position in milliseconds. The value is unused if no ad is
       * playing.
       *
       * <p>This position will be converted to an advancing {@link PositionSupplier} if the overall
       * state indicates an advancing ad playback position.
       *
       * <p>This method overrides any other {@link PositionSupplier} set via {@link
       * #setAdPositionMs(PositionSupplier)}.
       *
       * @param positionMs The current ad playback position in milliseconds.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setAdPositionMs(long positionMs) {
        this.adPositionMs = positionMs;
        return this;
      }

      /**
       * Sets the {@link PositionSupplier} for the current ad playback position in milliseconds. The
       * value is unused if no ad is playing.
       *
       * <p>The supplier is expected to return the updated position on every call if the playback is
       * advancing, for example by using {@link PositionSupplier#getExtrapolating}.
       *
       * <p>This method overrides any other position set via {@link #setAdPositionMs(long)}.
       *
       * @param adPositionMsSupplier The {@link PositionSupplier} for the current ad playback
       *     position in milliseconds. The value is unused if no ad is playing.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setAdPositionMs(PositionSupplier adPositionMsSupplier) {
        this.adPositionMs = null;
        this.adPositionMsSupplier = adPositionMsSupplier;
        return this;
      }

      /**
       * Sets the {@link PositionSupplier} for the estimated position up to which the currently
       * playing content is buffered, in milliseconds.
       *
       * @param contentBufferedPositionMsSupplier The {@link PositionSupplier} for the estimated
       *     position up to which the currently playing content is buffered, in milliseconds, or
       *     {@link C#TIME_UNSET} to indicate the default start position.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setContentBufferedPositionMs(
          PositionSupplier contentBufferedPositionMsSupplier) {
        this.contentBufferedPositionMsSupplier = contentBufferedPositionMsSupplier;
        return this;
      }

      /**
       * Sets the {@link PositionSupplier} for the estimated position up to which the currently
       * playing ad is buffered, in milliseconds. The value is unused if no ad is playing.
       *
       * @param adBufferedPositionMsSupplier The {@link PositionSupplier} for the estimated position
       *     up to which the currently playing ad is buffered, in milliseconds. The value is unused
       *     if no ad is playing.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setAdBufferedPositionMs(PositionSupplier adBufferedPositionMsSupplier) {
        this.adBufferedPositionMsSupplier = adBufferedPositionMsSupplier;
        return this;
      }

      /**
       * Sets the {@link PositionSupplier} for the estimated total buffered duration in
       * milliseconds.
       *
       * @param totalBufferedDurationMsSupplier The {@link PositionSupplier} for the estimated total
       *     buffered duration in milliseconds.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setTotalBufferedDurationMs(PositionSupplier totalBufferedDurationMsSupplier) {
        this.totalBufferedDurationMsSupplier = totalBufferedDurationMsSupplier;
        return this;
      }

      /**
       * Signals that a position discontinuity happened since the last player update and sets the
       * reason for it.
       *
       * @param positionDiscontinuityReason The {@linkplain Player.DiscontinuityReason reason} for
       *     the discontinuity.
       * @param discontinuityPositionMs The position, in milliseconds, in the current content or ad
       *     from which playback continues after the discontinuity.
       * @return This builder.
       * @see #clearPositionDiscontinuity
       */
      @CanIgnoreReturnValue
      public Builder setPositionDiscontinuity(
          @Player.DiscontinuityReason int positionDiscontinuityReason,
          long discontinuityPositionMs) {
        this.hasPositionDiscontinuity = true;
        this.positionDiscontinuityReason = positionDiscontinuityReason;
        this.discontinuityPositionMs = discontinuityPositionMs;
        return this;
      }

      /**
       * Clears a previously set position discontinuity signal.
       *
       * @return This builder.
       * @see #hasPositionDiscontinuity
       */
      @CanIgnoreReturnValue
      public Builder clearPositionDiscontinuity() {
        this.hasPositionDiscontinuity = false;
        return this;
      }

      /** Builds the {@link State}. */
      public State build() {
        return new State(this);
      }
    }

    /** The available {@link Commands}. */
    public final Commands availableCommands;
    /** Whether playback should proceed when ready and not suppressed. */
    public final boolean playWhenReady;
    /** The last reason for changing {@link #playWhenReady}. */
    public final @PlayWhenReadyChangeReason int playWhenReadyChangeReason;
    /** The {@linkplain Player.State state} of the player. */
    public final @Player.State int playbackState;
    /** The reason why playback is suppressed even if {@link #getPlayWhenReady()} is true. */
    public final @PlaybackSuppressionReason int playbackSuppressionReason;
    /** The last error that caused playback to fail, or null if there was no error. */
    @Nullable public final PlaybackException playerError;
    /** The {@link RepeatMode} used for playback. */
    public final @RepeatMode int repeatMode;
    /** Whether shuffling of media items is enabled. */
    public final boolean shuffleModeEnabled;
    /** Whether the player is currently loading its source. */
    public final boolean isLoading;
    /** The {@link Player#seekBack()} increment in milliseconds. */
    public final long seekBackIncrementMs;
    /** The {@link Player#seekForward()} increment in milliseconds. */
    public final long seekForwardIncrementMs;
    /**
     * The maximum position for which {@link #seekToPrevious()} seeks to the previous item, in
     * milliseconds.
     */
    public final long maxSeekToPreviousPositionMs;
    /** The currently active {@link PlaybackParameters}. */
    public final PlaybackParameters playbackParameters;
    /** The currently active {@link TrackSelectionParameters}. */
    public final TrackSelectionParameters trackSelectionParameters;
    /** The current {@link AudioAttributes}. */
    public final AudioAttributes audioAttributes;
    /** The current audio volume, with 0 being silence and 1 being unity gain (signal unchanged). */
    @FloatRange(from = 0, to = 1.0)
    public final float volume;
    /** The current video size. */
    public final VideoSize videoSize;
    /** The current {@linkplain CueGroup cues}. */
    public final CueGroup currentCues;
    /** The {@link DeviceInfo}. */
    public final DeviceInfo deviceInfo;
    /** The current device volume. */
    @IntRange(from = 0)
    public final int deviceVolume;
    /** Whether the device is muted. */
    public final boolean isDeviceMuted;
    /** The size of the surface onto which the video is being rendered. */
    public final Size surfaceSize;
    /**
     * Whether a frame has been rendered for the first time since setting the surface, a rendering
     * reset, or since the stream being rendered was changed.
     */
    public final boolean newlyRenderedFirstFrame;
    /** The most recent timed metadata. */
    public final Metadata timedMetadata;
    /** The media items in the playlist. */
    public final ImmutableList<MediaItemData> playlist;
    /** The {@link Timeline} derived from the {@link #playlist}. */
    public final Timeline timeline;
    /** The playlist {@link MediaMetadata}. */
    public final MediaMetadata playlistMetadata;
    /**
     * The current media item index, or {@link C#INDEX_UNSET} to assume the default first item of
     * the playlist is played.
     */
    public final int currentMediaItemIndex;
    /** The current ad group index, or {@link C#INDEX_UNSET} if no ad is playing. */
    public final int currentAdGroupIndex;
    /** The current ad index in the ad group, or {@link C#INDEX_UNSET} if no ad is playing. */
    public final int currentAdIndexInAdGroup;
    /**
     * The {@link PositionSupplier} for the current content playback position in milliseconds, or
     * {@link C#TIME_UNSET} to indicate the default start position.
     */
    public final PositionSupplier contentPositionMsSupplier;
    /**
     * The {@link PositionSupplier} for the current ad playback position in milliseconds. The value
     * is unused if no ad is playing.
     */
    public final PositionSupplier adPositionMsSupplier;
    /**
     * The {@link PositionSupplier} for the estimated position up to which the currently playing
     * content is buffered, in milliseconds, or {@link C#TIME_UNSET} to indicate the default start
     * position.
     */
    public final PositionSupplier contentBufferedPositionMsSupplier;
    /**
     * The {@link PositionSupplier} for the estimated position up to which the currently playing ad
     * is buffered, in milliseconds. The value is unused if no ad is playing.
     */
    public final PositionSupplier adBufferedPositionMsSupplier;
    /** The {@link PositionSupplier} for the estimated total buffered duration in milliseconds. */
    public final PositionSupplier totalBufferedDurationMsSupplier;
    /** Signals that a position discontinuity happened since the last update to the player. */
    public final boolean hasPositionDiscontinuity;
    /**
     * The {@linkplain Player.DiscontinuityReason reason} for the last position discontinuity. The
     * value is unused if {@link #hasPositionDiscontinuity} is {@code false}.
     */
    public final @Player.DiscontinuityReason int positionDiscontinuityReason;
    /**
     * The position, in milliseconds, in the current content or ad from which playback continued
     * after the discontinuity. The value is unused if {@link #hasPositionDiscontinuity} is {@code
     * false}.
     */
    public final long discontinuityPositionMs;

    private State(Builder builder) {
      if (builder.timeline.isEmpty()) {
        checkArgument(
            builder.playbackState == Player.STATE_IDLE
                || builder.playbackState == Player.STATE_ENDED,
            "Empty playlist only allowed in STATE_IDLE or STATE_ENDED");
        checkArgument(
            builder.currentAdGroupIndex == C.INDEX_UNSET
                && builder.currentAdIndexInAdGroup == C.INDEX_UNSET,
            "Ads not allowed if playlist is empty");
      } else {
        int mediaItemIndex = builder.currentMediaItemIndex;
        if (mediaItemIndex == C.INDEX_UNSET) {
          mediaItemIndex = 0; // TODO: Use shuffle order to find first index.
        } else {
          checkArgument(
              builder.currentMediaItemIndex < builder.timeline.getWindowCount(),
              "currentMediaItemIndex must be less than playlist.size()");
        }
        if (builder.currentAdGroupIndex != C.INDEX_UNSET) {
          Timeline.Period period = new Timeline.Period();
          Timeline.Window window = new Timeline.Window();
          long contentPositionMs =
              builder.contentPositionMs != null
                  ? builder.contentPositionMs
                  : builder.contentPositionMsSupplier.get();
          int periodIndex =
              getPeriodIndexFromWindowPosition(
                  builder.timeline, mediaItemIndex, contentPositionMs, window, period);
          builder.timeline.getPeriod(periodIndex, period);
          checkArgument(
              builder.currentAdGroupIndex < period.getAdGroupCount(),
              "PeriodData has less ad groups than adGroupIndex");
          int adCountInGroup = period.getAdCountInAdGroup(builder.currentAdGroupIndex);
          if (adCountInGroup != C.LENGTH_UNSET) {
            checkArgument(
                builder.currentAdIndexInAdGroup < adCountInGroup,
                "Ad group has less ads than adIndexInGroupIndex");
          }
        }
      }
      if (builder.playerError != null) {
        checkArgument(
            builder.playbackState == Player.STATE_IDLE, "Player error only allowed in STATE_IDLE");
      }
      if (builder.playbackState == Player.STATE_IDLE
          || builder.playbackState == Player.STATE_ENDED) {
        checkArgument(
            !builder.isLoading, "isLoading only allowed when not in STATE_IDLE or STATE_ENDED");
      }
      PositionSupplier contentPositionMsSupplier = builder.contentPositionMsSupplier;
      if (builder.contentPositionMs != null) {
        if (builder.currentAdGroupIndex == C.INDEX_UNSET
            && builder.playWhenReady
            && builder.playbackState == Player.STATE_READY
            && builder.playbackSuppressionReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE
            && builder.contentPositionMs != C.TIME_UNSET) {
          contentPositionMsSupplier =
              PositionSupplier.getExtrapolating(
                  builder.contentPositionMs, builder.playbackParameters.speed);
        } else {
          contentPositionMsSupplier = PositionSupplier.getConstant(builder.contentPositionMs);
        }
      }
      PositionSupplier adPositionMsSupplier = builder.adPositionMsSupplier;
      if (builder.adPositionMs != null) {
        if (builder.currentAdGroupIndex != C.INDEX_UNSET
            && builder.playWhenReady
            && builder.playbackState == Player.STATE_READY
            && builder.playbackSuppressionReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE) {
          adPositionMsSupplier =
              PositionSupplier.getExtrapolating(builder.adPositionMs, /* playbackSpeed= */ 1f);
        } else {
          adPositionMsSupplier = PositionSupplier.getConstant(builder.adPositionMs);
        }
      }
      this.availableCommands = builder.availableCommands;
      this.playWhenReady = builder.playWhenReady;
      this.playWhenReadyChangeReason = builder.playWhenReadyChangeReason;
      this.playbackState = builder.playbackState;
      this.playbackSuppressionReason = builder.playbackSuppressionReason;
      this.playerError = builder.playerError;
      this.repeatMode = builder.repeatMode;
      this.shuffleModeEnabled = builder.shuffleModeEnabled;
      this.isLoading = builder.isLoading;
      this.seekBackIncrementMs = builder.seekBackIncrementMs;
      this.seekForwardIncrementMs = builder.seekForwardIncrementMs;
      this.maxSeekToPreviousPositionMs = builder.maxSeekToPreviousPositionMs;
      this.playbackParameters = builder.playbackParameters;
      this.trackSelectionParameters = builder.trackSelectionParameters;
      this.audioAttributes = builder.audioAttributes;
      this.volume = builder.volume;
      this.videoSize = builder.videoSize;
      this.currentCues = builder.currentCues;
      this.deviceInfo = builder.deviceInfo;
      this.deviceVolume = builder.deviceVolume;
      this.isDeviceMuted = builder.isDeviceMuted;
      this.surfaceSize = builder.surfaceSize;
      this.newlyRenderedFirstFrame = builder.newlyRenderedFirstFrame;
      this.timedMetadata = builder.timedMetadata;
      this.playlist = builder.playlist;
      this.timeline = builder.timeline;
      this.playlistMetadata = builder.playlistMetadata;
      this.currentMediaItemIndex = builder.currentMediaItemIndex;
      this.currentAdGroupIndex = builder.currentAdGroupIndex;
      this.currentAdIndexInAdGroup = builder.currentAdIndexInAdGroup;
      this.contentPositionMsSupplier = contentPositionMsSupplier;
      this.adPositionMsSupplier = adPositionMsSupplier;
      this.contentBufferedPositionMsSupplier = builder.contentBufferedPositionMsSupplier;
      this.adBufferedPositionMsSupplier = builder.adBufferedPositionMsSupplier;
      this.totalBufferedDurationMsSupplier = builder.totalBufferedDurationMsSupplier;
      this.hasPositionDiscontinuity = builder.hasPositionDiscontinuity;
      this.positionDiscontinuityReason = builder.positionDiscontinuityReason;
      this.discontinuityPositionMs = builder.discontinuityPositionMs;
    }

    /** Returns a {@link Builder} pre-populated with the current state values. */
    public Builder buildUpon() {
      return new Builder(this);
    }

    @Override
    public boolean equals(@Nullable Object o) {
      if (this == o) {
        return true;
      }
      if (!(o instanceof State)) {
        return false;
      }
      State state = (State) o;
      return playWhenReady == state.playWhenReady
          && playWhenReadyChangeReason == state.playWhenReadyChangeReason
          && availableCommands.equals(state.availableCommands)
          && playbackState == state.playbackState
          && playbackSuppressionReason == state.playbackSuppressionReason
          && Util.areEqual(playerError, state.playerError)
          && repeatMode == state.repeatMode
          && shuffleModeEnabled == state.shuffleModeEnabled
          && isLoading == state.isLoading
          && seekBackIncrementMs == state.seekBackIncrementMs
          && seekForwardIncrementMs == state.seekForwardIncrementMs
          && maxSeekToPreviousPositionMs == state.maxSeekToPreviousPositionMs
          && playbackParameters.equals(state.playbackParameters)
          && trackSelectionParameters.equals(state.trackSelectionParameters)
          && audioAttributes.equals(state.audioAttributes)
          && volume == state.volume
          && videoSize.equals(state.videoSize)
          && currentCues.equals(state.currentCues)
          && deviceInfo.equals(state.deviceInfo)
          && deviceVolume == state.deviceVolume
          && isDeviceMuted == state.isDeviceMuted
          && surfaceSize.equals(state.surfaceSize)
          && newlyRenderedFirstFrame == state.newlyRenderedFirstFrame
          && timedMetadata.equals(state.timedMetadata)
          && playlist.equals(state.playlist)
          && playlistMetadata.equals(state.playlistMetadata)
          && currentMediaItemIndex == state.currentMediaItemIndex
          && currentAdGroupIndex == state.currentAdGroupIndex
          && currentAdIndexInAdGroup == state.currentAdIndexInAdGroup
          && contentPositionMsSupplier.equals(state.contentPositionMsSupplier)
          && adPositionMsSupplier.equals(state.adPositionMsSupplier)
          && contentBufferedPositionMsSupplier.equals(state.contentBufferedPositionMsSupplier)
          && adBufferedPositionMsSupplier.equals(state.adBufferedPositionMsSupplier)
          && totalBufferedDurationMsSupplier.equals(state.totalBufferedDurationMsSupplier)
          && hasPositionDiscontinuity == state.hasPositionDiscontinuity
          && positionDiscontinuityReason == state.positionDiscontinuityReason
          && discontinuityPositionMs == state.discontinuityPositionMs;
    }

    @Override
    public int hashCode() {
      int result = 7;
      result = 31 * result + availableCommands.hashCode();
      result = 31 * result + (playWhenReady ? 1 : 0);
      result = 31 * result + playWhenReadyChangeReason;
      result = 31 * result + playbackState;
      result = 31 * result + playbackSuppressionReason;
      result = 31 * result + (playerError == null ? 0 : playerError.hashCode());
      result = 31 * result + repeatMode;
      result = 31 * result + (shuffleModeEnabled ? 1 : 0);
      result = 31 * result + (isLoading ? 1 : 0);
      result = 31 * result + (int) (seekBackIncrementMs ^ (seekBackIncrementMs >>> 32));
      result = 31 * result + (int) (seekForwardIncrementMs ^ (seekForwardIncrementMs >>> 32));
      result =
          31 * result + (int) (maxSeekToPreviousPositionMs ^ (maxSeekToPreviousPositionMs >>> 32));
      result = 31 * result + playbackParameters.hashCode();
      result = 31 * result + trackSelectionParameters.hashCode();
      result = 31 * result + audioAttributes.hashCode();
      result = 31 * result + Float.floatToRawIntBits(volume);
      result = 31 * result + videoSize.hashCode();
      result = 31 * result + currentCues.hashCode();
      result = 31 * result + deviceInfo.hashCode();
      result = 31 * result + deviceVolume;
      result = 31 * result + (isDeviceMuted ? 1 : 0);
      result = 31 * result + surfaceSize.hashCode();
      result = 31 * result + (newlyRenderedFirstFrame ? 1 : 0);
      result = 31 * result + timedMetadata.hashCode();
      result = 31 * result + playlist.hashCode();
      result = 31 * result + playlistMetadata.hashCode();
      result = 31 * result + currentMediaItemIndex;
      result = 31 * result + currentAdGroupIndex;
      result = 31 * result + currentAdIndexInAdGroup;
      result = 31 * result + contentPositionMsSupplier.hashCode();
      result = 31 * result + adPositionMsSupplier.hashCode();
      result = 31 * result + contentBufferedPositionMsSupplier.hashCode();
      result = 31 * result + adBufferedPositionMsSupplier.hashCode();
      result = 31 * result + totalBufferedDurationMsSupplier.hashCode();
      result = 31 * result + (hasPositionDiscontinuity ? 1 : 0);
      result = 31 * result + positionDiscontinuityReason;
      result = 31 * result + (int) (discontinuityPositionMs ^ (discontinuityPositionMs >>> 32));
      return result;
    }
  }

  private static final class PlaylistTimeline extends Timeline {

    private final ImmutableList<MediaItemData> playlist;
    private final int[] firstPeriodIndexByWindowIndex;
    private final int[] windowIndexByPeriodIndex;
    private final HashMap<Object, Integer> periodIndexByUid;

    public PlaylistTimeline(ImmutableList<MediaItemData> playlist) {
      int mediaItemCount = playlist.size();
      this.playlist = playlist;
      this.firstPeriodIndexByWindowIndex = new int[mediaItemCount];
      int periodCount = 0;
      for (int i = 0; i < mediaItemCount; i++) {
        MediaItemData mediaItemData = playlist.get(i);
        firstPeriodIndexByWindowIndex[i] = periodCount;
        periodCount += getPeriodCountInMediaItem(mediaItemData);
      }
      this.windowIndexByPeriodIndex = new int[periodCount];
      this.periodIndexByUid = new HashMap<>();
      int periodIndex = 0;
      for (int i = 0; i < mediaItemCount; i++) {
        MediaItemData mediaItemData = playlist.get(i);
        for (int j = 0; j < getPeriodCountInMediaItem(mediaItemData); j++) {
          periodIndexByUid.put(mediaItemData.getPeriodUid(j), periodIndex);
          windowIndexByPeriodIndex[periodIndex] = i;
          periodIndex++;
        }
      }
    }

    @Override
    public int getWindowCount() {
      return playlist.size();
    }

    @Override
    public int getNextWindowIndex(int windowIndex, int repeatMode, boolean shuffleModeEnabled) {
      // TODO: Support shuffle order.
      return super.getNextWindowIndex(windowIndex, repeatMode, shuffleModeEnabled);
    }

    @Override
    public int getPreviousWindowIndex(int windowIndex, int repeatMode, boolean shuffleModeEnabled) {
      // TODO: Support shuffle order.
      return super.getPreviousWindowIndex(windowIndex, repeatMode, shuffleModeEnabled);
    }

    @Override
    public int getLastWindowIndex(boolean shuffleModeEnabled) {
      // TODO: Support shuffle order.
      return super.getLastWindowIndex(shuffleModeEnabled);
    }

    @Override
    public int getFirstWindowIndex(boolean shuffleModeEnabled) {
      // TODO: Support shuffle order.
      return super.getFirstWindowIndex(shuffleModeEnabled);
    }

    @Override
    public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
      return playlist
          .get(windowIndex)
          .getWindow(firstPeriodIndexByWindowIndex[windowIndex], window);
    }

    @Override
    public int getPeriodCount() {
      return windowIndexByPeriodIndex.length;
    }

    @Override
    public Period getPeriodByUid(Object periodUid, Period period) {
      int periodIndex = checkNotNull(periodIndexByUid.get(periodUid));
      return getPeriod(periodIndex, period, /* setIds= */ true);
    }

    @Override
    public Period getPeriod(int periodIndex, Period period, boolean setIds) {
      int windowIndex = windowIndexByPeriodIndex[periodIndex];
      int periodIndexInWindow = periodIndex - firstPeriodIndexByWindowIndex[windowIndex];
      return playlist.get(windowIndex).getPeriod(windowIndex, periodIndexInWindow, period);
    }

    @Override
    public int getIndexOfPeriod(Object uid) {
      @Nullable Integer index = periodIndexByUid.get(uid);
      return index == null ? C.INDEX_UNSET : index;
    }

    @Override
    public Object getUidOfPeriod(int periodIndex) {
      int windowIndex = windowIndexByPeriodIndex[periodIndex];
      int periodIndexInWindow = periodIndex - firstPeriodIndexByWindowIndex[windowIndex];
      return playlist.get(windowIndex).getPeriodUid(periodIndexInWindow);
    }

    private static int getPeriodCountInMediaItem(MediaItemData mediaItemData) {
      return mediaItemData.periods.isEmpty() ? 1 : mediaItemData.periods.size();
    }
  }

  /**
   * An immutable description of an item in the playlist, containing both static setup information
   * like {@link MediaItem} and dynamic data that is generally read from the media like the
   * duration.
   */
  protected static final class MediaItemData {

    /** A builder for {@link MediaItemData} objects. */
    public static final class Builder {

      private Object uid;
      private Tracks tracks;
      private MediaItem mediaItem;
      @Nullable private MediaMetadata mediaMetadata;
      @Nullable private Object manifest;
      @Nullable private MediaItem.LiveConfiguration liveConfiguration;
      private long presentationStartTimeMs;
      private long windowStartTimeMs;
      private long elapsedRealtimeEpochOffsetMs;
      private boolean isSeekable;
      private boolean isDynamic;
      private long defaultPositionUs;
      private long durationUs;
      private long positionInFirstPeriodUs;
      private boolean isPlaceholder;
      private ImmutableList<PeriodData> periods;

      /**
       * Creates the builder.
       *
       * @param uid The unique identifier of the media item within a playlist. This value will be
       *     set as {@link Timeline.Window#uid} for this item.
       */
      public Builder(Object uid) {
        this.uid = uid;
        tracks = Tracks.EMPTY;
        mediaItem = MediaItem.EMPTY;
        mediaMetadata = null;
        manifest = null;
        liveConfiguration = null;
        presentationStartTimeMs = C.TIME_UNSET;
        windowStartTimeMs = C.TIME_UNSET;
        elapsedRealtimeEpochOffsetMs = C.TIME_UNSET;
        isSeekable = false;
        isDynamic = false;
        defaultPositionUs = 0;
        durationUs = C.TIME_UNSET;
        positionInFirstPeriodUs = 0;
        isPlaceholder = false;
        periods = ImmutableList.of();
      }

      private Builder(MediaItemData mediaItemData) {
        this.uid = mediaItemData.uid;
        this.tracks = mediaItemData.tracks;
        this.mediaItem = mediaItemData.mediaItem;
        this.mediaMetadata = mediaItemData.mediaMetadata;
        this.manifest = mediaItemData.manifest;
        this.liveConfiguration = mediaItemData.liveConfiguration;
        this.presentationStartTimeMs = mediaItemData.presentationStartTimeMs;
        this.windowStartTimeMs = mediaItemData.windowStartTimeMs;
        this.elapsedRealtimeEpochOffsetMs = mediaItemData.elapsedRealtimeEpochOffsetMs;
        this.isSeekable = mediaItemData.isSeekable;
        this.isDynamic = mediaItemData.isDynamic;
        this.defaultPositionUs = mediaItemData.defaultPositionUs;
        this.durationUs = mediaItemData.durationUs;
        this.positionInFirstPeriodUs = mediaItemData.positionInFirstPeriodUs;
        this.isPlaceholder = mediaItemData.isPlaceholder;
        this.periods = mediaItemData.periods;
      }

      /**
       * Sets the unique identifier of this media item within a playlist.
       *
       * <p>This value will be set as {@link Timeline.Window#uid} for this item.
       *
       * @param uid The unique identifier of this media item within a playlist.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setUid(Object uid) {
        this.uid = uid;
        return this;
      }

      /**
       * Sets the {@link Tracks} of this media item.
       *
       * @param tracks The {@link Tracks} of this media item.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setTracks(Tracks tracks) {
        this.tracks = tracks;
        return this;
      }

      /**
       * Sets the {@link MediaItem}.
       *
       * @param mediaItem The {@link MediaItem}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setMediaItem(MediaItem mediaItem) {
        this.mediaItem = mediaItem;
        return this;
      }

      /**
       * Sets the {@link MediaMetadata}.
       *
       * <p>This data includes static data from the {@link MediaItem#mediaMetadata MediaItem} and
       * the media's {@link Format#metadata Format}, as well any dynamic metadata that has been
       * parsed from the media. If null, the metadata is assumed to be the simple combination of the
       * {@link MediaItem#mediaMetadata MediaItem} metadata and the metadata of the selected {@link
       * Format#metadata Formats}.
       *
       * @param mediaMetadata The {@link MediaMetadata}, or null to assume that the metadata is the
       *     simple combination of the {@link MediaItem#mediaMetadata MediaItem} metadata and the
       *     metadata of the selected {@link Format#metadata Formats}.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setMediaMetadata(@Nullable MediaMetadata mediaMetadata) {
        this.mediaMetadata = mediaMetadata;
        return this;
      }

      /**
       * Sets the manifest of the media item.
       *
       * @param manifest The manifest of the media item, or null if not applicable.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setManifest(@Nullable Object manifest) {
        this.manifest = manifest;
        return this;
      }

      /**
       * Sets the active {@link MediaItem.LiveConfiguration}, or null if the media item is not live.
       *
       * @param liveConfiguration The active {@link MediaItem.LiveConfiguration}, or null if the
       *     media item is not live.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setLiveConfiguration(@Nullable MediaItem.LiveConfiguration liveConfiguration) {
        this.liveConfiguration = liveConfiguration;
        return this;
      }

      /**
       * Sets the start time of the live presentation.
       *
       * <p>This value can only be set to anything other than {@link C#TIME_UNSET} if the stream is
       * {@linkplain #setLiveConfiguration live}.
       *
       * @param presentationStartTimeMs The start time of the live presentation, in milliseconds
       *     since the Unix epoch, or {@link C#TIME_UNSET} if unknown or not applicable.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPresentationStartTimeMs(long presentationStartTimeMs) {
        this.presentationStartTimeMs = presentationStartTimeMs;
        return this;
      }

      /**
       * Sets the start time of the live window.
       *
       * <p>This value can only be set to anything other than {@link C#TIME_UNSET} if the stream is
       * {@linkplain #setLiveConfiguration live}. The value should also be greater or equal than the
       * {@linkplain #setPresentationStartTimeMs presentation start time}, if set.
       *
       * @param windowStartTimeMs The start time of the live window, in milliseconds since the Unix
       *     epoch, or {@link C#TIME_UNSET} if unknown or not applicable.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setWindowStartTimeMs(long windowStartTimeMs) {
        this.windowStartTimeMs = windowStartTimeMs;
        return this;
      }

      /**
       * Sets the offset between {@link SystemClock#elapsedRealtime()} and the time since the Unix
       * epoch according to the clock of the media origin server.
       *
       * <p>This value can only be set to anything other than {@link C#TIME_UNSET} if the stream is
       * {@linkplain #setLiveConfiguration live}.
       *
       * @param elapsedRealtimeEpochOffsetMs The offset between {@link
       *     SystemClock#elapsedRealtime()} and the time since the Unix epoch according to the clock
       *     of the media origin server, or {@link C#TIME_UNSET} if unknown or not applicable.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setElapsedRealtimeEpochOffsetMs(long elapsedRealtimeEpochOffsetMs) {
        this.elapsedRealtimeEpochOffsetMs = elapsedRealtimeEpochOffsetMs;
        return this;
      }

      /**
       * Sets whether it's possible to seek within this media item.
       *
       * @param isSeekable Whether it's possible to seek within this media item.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setIsSeekable(boolean isSeekable) {
        this.isSeekable = isSeekable;
        return this;
      }

      /**
       * Sets whether this media item may change over time, for example a moving live window.
       *
       * @param isDynamic Whether this media item may change over time, for example a moving live
       *     window.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setIsDynamic(boolean isDynamic) {
        this.isDynamic = isDynamic;
        return this;
      }

      /**
       * Sets the default position relative to the start of the media item at which to begin
       * playback, in microseconds.
       *
       * <p>The default position must be less or equal to the {@linkplain #setDurationUs duration},
       * is set.
       *
       * @param defaultPositionUs The default position relative to the start of the media item at
       *     which to begin playback, in microseconds.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setDefaultPositionUs(long defaultPositionUs) {
        checkArgument(defaultPositionUs >= 0);
        this.defaultPositionUs = defaultPositionUs;
        return this;
      }

      /**
       * Sets the duration of the media item, in microseconds.
       *
       * <p>If both this duration and all {@linkplain #setPeriods period} durations are set, the sum
       * of this duration and the {@linkplain #setPositionInFirstPeriodUs offset in the first
       * period} must match the total duration of all periods.
       *
       * @param durationUs The duration of the media item, in microseconds, or {@link C#TIME_UNSET}
       *     if unknown.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setDurationUs(long durationUs) {
        checkArgument(durationUs == C.TIME_UNSET || durationUs >= 0);
        this.durationUs = durationUs;
        return this;
      }

      /**
       * Sets the position of the start of this media item relative to the start of the first period
       * belonging to it, in microseconds.
       *
       * @param positionInFirstPeriodUs The position of the start of this media item relative to the
       *     start of the first period belonging to it, in microseconds.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPositionInFirstPeriodUs(long positionInFirstPeriodUs) {
        checkArgument(positionInFirstPeriodUs >= 0);
        this.positionInFirstPeriodUs = positionInFirstPeriodUs;
        return this;
      }

      /**
       * Sets whether this media item contains placeholder information because the real information
       * has yet to be loaded.
       *
       * @param isPlaceholder Whether this media item contains placeholder information because the
       *     real information has yet to be loaded.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setIsPlaceholder(boolean isPlaceholder) {
        this.isPlaceholder = isPlaceholder;
        return this;
      }

      /**
       * Sets the list of {@linkplain PeriodData periods} in this media item.
       *
       * <p>All periods must have unique {@linkplain PeriodData.Builder#setUid UIDs} and only the
       * last period is allowed to have an unset {@linkplain PeriodData.Builder#setDurationUs
       * duration}.
       *
       * @param periods The list of {@linkplain PeriodData periods} in this media item, or an empty
       *     list to assume a single period without ads and the same duration as the media item.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setPeriods(List<PeriodData> periods) {
        int periodCount = periods.size();
        for (int i = 0; i < periodCount - 1; i++) {
          checkArgument(
              periods.get(i).durationUs != C.TIME_UNSET, "Periods other than last need a duration");
          for (int j = i + 1; j < periodCount; j++) {
            checkArgument(
                !periods.get(i).uid.equals(periods.get(j).uid),
                "Duplicate PeriodData UIDs in period list");
          }
        }
        this.periods = ImmutableList.copyOf(periods);
        return this;
      }

      /** Builds the {@link MediaItemData}. */
      public MediaItemData build() {
        return new MediaItemData(this);
      }
    }

    /** The unique identifier of this media item. */
    public final Object uid;
    /** The {@link Tracks} of this media item. */
    public final Tracks tracks;
    /** The {@link MediaItem}. */
    public final MediaItem mediaItem;
    /**
     * The {@link MediaMetadata}, including static data from the {@link MediaItem#mediaMetadata
     * MediaItem} and the media's {@link Format#metadata Format}, as well any dynamic metadata that
     * has been parsed from the media. If null, the metadata is assumed to be the simple combination
     * of the {@link MediaItem#mediaMetadata MediaItem} metadata and the metadata of the selected
     * {@link Format#metadata Formats}.
     */
    @Nullable public final MediaMetadata mediaMetadata;
    /** The manifest of the media item, or null if not applicable. */
    @Nullable public final Object manifest;
    /** The active {@link MediaItem.LiveConfiguration}, or null if the media item is not live. */
    @Nullable public final MediaItem.LiveConfiguration liveConfiguration;
    /**
     * The start time of the live presentation, in milliseconds since the Unix epoch, or {@link
     * C#TIME_UNSET} if unknown or not applicable.
     */
    public final long presentationStartTimeMs;
    /**
     * The start time of the live window, in milliseconds since the Unix epoch, or {@link
     * C#TIME_UNSET} if unknown or not applicable.
     */
    public final long windowStartTimeMs;
    /**
     * The offset between {@link SystemClock#elapsedRealtime()} and the time since the Unix epoch
     * according to the clock of the media origin server, or {@link C#TIME_UNSET} if unknown or not
     * applicable.
     */
    public final long elapsedRealtimeEpochOffsetMs;
    /** Whether it's possible to seek within this media item. */
    public final boolean isSeekable;
    /** Whether this media item may change over time, for example a moving live window. */
    public final boolean isDynamic;
    /**
     * The default position relative to the start of the media item at which to begin playback, in
     * microseconds.
     */
    public final long defaultPositionUs;
    /** The duration of the media item, in microseconds, or {@link C#TIME_UNSET} if unknown. */
    public final long durationUs;
    /**
     * The position of the start of this media item relative to the start of the first period
     * belonging to it, in microseconds.
     */
    public final long positionInFirstPeriodUs;
    /**
     * Whether this media item contains placeholder information because the real information has yet
     * to be loaded.
     */
    public final boolean isPlaceholder;
    /**
     * The list of {@linkplain PeriodData periods} in this media item, or an empty list to assume a
     * single period without ads and the same duration as the media item.
     */
    public final ImmutableList<PeriodData> periods;

    private final long[] periodPositionInWindowUs;
    private final MediaMetadata combinedMediaMetadata;

    private MediaItemData(Builder builder) {
      if (builder.liveConfiguration == null) {
        checkArgument(
            builder.presentationStartTimeMs == C.TIME_UNSET,
            "presentationStartTimeMs can only be set if liveConfiguration != null");
        checkArgument(
            builder.windowStartTimeMs == C.TIME_UNSET,
            "windowStartTimeMs can only be set if liveConfiguration != null");
        checkArgument(
            builder.elapsedRealtimeEpochOffsetMs == C.TIME_UNSET,
            "elapsedRealtimeEpochOffsetMs can only be set if liveConfiguration != null");
      } else if (builder.presentationStartTimeMs != C.TIME_UNSET
          && builder.windowStartTimeMs != C.TIME_UNSET) {
        checkArgument(
            builder.windowStartTimeMs >= builder.presentationStartTimeMs,
            "windowStartTimeMs can't be less than presentationStartTimeMs");
      }
      int periodCount = builder.periods.size();
      if (builder.durationUs != C.TIME_UNSET) {
        checkArgument(
            builder.defaultPositionUs <= builder.durationUs,
            "defaultPositionUs can't be greater than durationUs");
      }
      this.uid = builder.uid;
      this.tracks = builder.tracks;
      this.mediaItem = builder.mediaItem;
      this.mediaMetadata = builder.mediaMetadata;
      this.manifest = builder.manifest;
      this.liveConfiguration = builder.liveConfiguration;
      this.presentationStartTimeMs = builder.presentationStartTimeMs;
      this.windowStartTimeMs = builder.windowStartTimeMs;
      this.elapsedRealtimeEpochOffsetMs = builder.elapsedRealtimeEpochOffsetMs;
      this.isSeekable = builder.isSeekable;
      this.isDynamic = builder.isDynamic;
      this.defaultPositionUs = builder.defaultPositionUs;
      this.durationUs = builder.durationUs;
      this.positionInFirstPeriodUs = builder.positionInFirstPeriodUs;
      this.isPlaceholder = builder.isPlaceholder;
      this.periods = builder.periods;
      periodPositionInWindowUs = new long[periods.size()];
      if (!periods.isEmpty()) {
        periodPositionInWindowUs[0] = -positionInFirstPeriodUs;
        for (int i = 0; i < periodCount - 1; i++) {
          periodPositionInWindowUs[i + 1] = periodPositionInWindowUs[i] + periods.get(i).durationUs;
        }
      }
      combinedMediaMetadata =
          mediaMetadata != null ? mediaMetadata : getCombinedMediaMetadata(mediaItem, tracks);
    }

    /** Returns a {@link Builder} pre-populated with the current values. */
    public Builder buildUpon() {
      return new Builder(this);
    }

    @Override
    public boolean equals(@Nullable Object o) {
      if (this == o) {
        return true;
      }
      if (!(o instanceof MediaItemData)) {
        return false;
      }
      MediaItemData mediaItemData = (MediaItemData) o;
      return this.uid.equals(mediaItemData.uid)
          && this.tracks.equals(mediaItemData.tracks)
          && this.mediaItem.equals(mediaItemData.mediaItem)
          && Util.areEqual(this.mediaMetadata, mediaItemData.mediaMetadata)
          && Util.areEqual(this.manifest, mediaItemData.manifest)
          && Util.areEqual(this.liveConfiguration, mediaItemData.liveConfiguration)
          && this.presentationStartTimeMs == mediaItemData.presentationStartTimeMs
          && this.windowStartTimeMs == mediaItemData.windowStartTimeMs
          && this.elapsedRealtimeEpochOffsetMs == mediaItemData.elapsedRealtimeEpochOffsetMs
          && this.isSeekable == mediaItemData.isSeekable
          && this.isDynamic == mediaItemData.isDynamic
          && this.defaultPositionUs == mediaItemData.defaultPositionUs
          && this.durationUs == mediaItemData.durationUs
          && this.positionInFirstPeriodUs == mediaItemData.positionInFirstPeriodUs
          && this.isPlaceholder == mediaItemData.isPlaceholder
          && this.periods.equals(mediaItemData.periods);
    }

    @Override
    public int hashCode() {
      int result = 7;
      result = 31 * result + uid.hashCode();
      result = 31 * result + tracks.hashCode();
      result = 31 * result + mediaItem.hashCode();
      result = 31 * result + (mediaMetadata == null ? 0 : mediaMetadata.hashCode());
      result = 31 * result + (manifest == null ? 0 : manifest.hashCode());
      result = 31 * result + (liveConfiguration == null ? 0 : liveConfiguration.hashCode());
      result = 31 * result + (int) (presentationStartTimeMs ^ (presentationStartTimeMs >>> 32));
      result = 31 * result + (int) (windowStartTimeMs ^ (windowStartTimeMs >>> 32));
      result =
          31 * result
              + (int) (elapsedRealtimeEpochOffsetMs ^ (elapsedRealtimeEpochOffsetMs >>> 32));
      result = 31 * result + (isSeekable ? 1 : 0);
      result = 31 * result + (isDynamic ? 1 : 0);
      result = 31 * result + (int) (defaultPositionUs ^ (defaultPositionUs >>> 32));
      result = 31 * result + (int) (durationUs ^ (durationUs >>> 32));
      result = 31 * result + (int) (positionInFirstPeriodUs ^ (positionInFirstPeriodUs >>> 32));
      result = 31 * result + (isPlaceholder ? 1 : 0);
      result = 31 * result + periods.hashCode();
      return result;
    }

    private Timeline.Window getWindow(int firstPeriodIndex, Timeline.Window window) {
      int periodCount = periods.isEmpty() ? 1 : periods.size();
      window.set(
          uid,
          mediaItem,
          manifest,
          presentationStartTimeMs,
          windowStartTimeMs,
          elapsedRealtimeEpochOffsetMs,
          isSeekable,
          isDynamic,
          liveConfiguration,
          defaultPositionUs,
          durationUs,
          firstPeriodIndex,
          /* lastPeriodIndex= */ firstPeriodIndex + periodCount - 1,
          positionInFirstPeriodUs);
      window.isPlaceholder = isPlaceholder;
      return window;
    }

    private Timeline.Period getPeriod(
        int windowIndex, int periodIndexInMediaItem, Timeline.Period period) {
      if (periods.isEmpty()) {
        period.set(
            /* id= */ uid,
            uid,
            windowIndex,
            /* durationUs= */ positionInFirstPeriodUs + durationUs,
            /* positionInWindowUs= */ 0,
            AdPlaybackState.NONE,
            isPlaceholder);
      } else {
        PeriodData periodData = periods.get(periodIndexInMediaItem);
        Object periodId = periodData.uid;
        Object periodUid = Pair.create(uid, periodId);
        period.set(
            periodId,
            periodUid,
            windowIndex,
            periodData.durationUs,
            periodPositionInWindowUs[periodIndexInMediaItem],
            periodData.adPlaybackState,
            periodData.isPlaceholder);
      }
      return period;
    }

    private Object getPeriodUid(int periodIndexInMediaItem) {
      if (periods.isEmpty()) {
        return uid;
      }
      Object periodId = periods.get(periodIndexInMediaItem).uid;
      return Pair.create(uid, periodId);
    }

    private static MediaMetadata getCombinedMediaMetadata(MediaItem mediaItem, Tracks tracks) {
      MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder();
      int trackGroupCount = tracks.getGroups().size();
      for (int i = 0; i < trackGroupCount; i++) {
        Tracks.Group group = tracks.getGroups().get(i);
        for (int j = 0; j < group.length; j++) {
          if (group.isTrackSelected(j)) {
            Format format = group.getTrackFormat(j);
            if (format.metadata != null) {
              for (int k = 0; k < format.metadata.length(); k++) {
                format.metadata.get(k).populateMediaMetadata(metadataBuilder);
              }
            }
          }
        }
      }
      return metadataBuilder.populate(mediaItem.mediaMetadata).build();
    }
  }

  /** Data describing the properties of a period inside a {@link MediaItemData}. */
  protected static final class PeriodData {

    /** A builder for {@link PeriodData} objects. */
    public static final class Builder {

      private Object uid;
      private long durationUs;
      private AdPlaybackState adPlaybackState;
      private boolean isPlaceholder;

      /**
       * Creates the builder.
       *
       * @param uid The unique identifier of the period within its media item.
       */
      public Builder(Object uid) {
        this.uid = uid;
        this.durationUs = 0;
        this.adPlaybackState = AdPlaybackState.NONE;
        this.isPlaceholder = false;
      }

      private Builder(PeriodData periodData) {
        this.uid = periodData.uid;
        this.durationUs = periodData.durationUs;
        this.adPlaybackState = periodData.adPlaybackState;
        this.isPlaceholder = periodData.isPlaceholder;
      }

      /**
       * Sets the unique identifier of the period within its media item.
       *
       * @param uid The unique identifier of the period within its media item.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setUid(Object uid) {
        this.uid = uid;
        return this;
      }

      /**
       * Sets the total duration of the period, in microseconds, or {@link C#TIME_UNSET} if unknown.
       *
       * <p>Only the last period in a media item can have an unknown duration.
       *
       * @param durationUs The total duration of the period, in microseconds, or {@link
       *     C#TIME_UNSET} if unknown.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setDurationUs(long durationUs) {
        checkArgument(durationUs == C.TIME_UNSET || durationUs >= 0);
        this.durationUs = durationUs;
        return this;
      }

      /**
       * Sets the {@link AdPlaybackState}.
       *
       * @param adPlaybackState The {@link AdPlaybackState}, or {@link AdPlaybackState#NONE} if
       *     there are no ads.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setAdPlaybackState(AdPlaybackState adPlaybackState) {
        this.adPlaybackState = adPlaybackState;
        return this;
      }

      /**
       * Sets whether this period contains placeholder information because the real information has
       * yet to be loaded
       *
       * @param isPlaceholder Whether this period contains placeholder information because the real
       *     information has yet to be loaded.
       * @return This builder.
       */
      @CanIgnoreReturnValue
      public Builder setIsPlaceholder(boolean isPlaceholder) {
        this.isPlaceholder = isPlaceholder;
        return this;
      }

      /** Builds the {@link PeriodData}. */
      public PeriodData build() {
        return new PeriodData(this);
      }
    }

    /** The unique identifier of the period within its media item. */
    public final Object uid;
    /**
     * The total duration of the period, in microseconds, or {@link C#TIME_UNSET} if unknown. Only
     * the last period in a media item can have an unknown duration.
     */
    public final long durationUs;
    /**
     * The {@link AdPlaybackState} of the period, or {@link AdPlaybackState#NONE} if there are no
     * ads.
     */
    public final AdPlaybackState adPlaybackState;
    /**
     * Whether this period contains placeholder information because the real information has yet to
     * be loaded.
     */
    public final boolean isPlaceholder;

    private PeriodData(Builder builder) {
      this.uid = builder.uid;
      this.durationUs = builder.durationUs;
      this.adPlaybackState = builder.adPlaybackState;
      this.isPlaceholder = builder.isPlaceholder;
    }

    /** Returns a {@link Builder} pre-populated with the current values. */
    public Builder buildUpon() {
      return new Builder(this);
    }

    @Override
    public boolean equals(@Nullable Object o) {
      if (this == o) {
        return true;
      }
      if (!(o instanceof PeriodData)) {
        return false;
      }
      PeriodData periodData = (PeriodData) o;
      return this.uid.equals(periodData.uid)
          && this.durationUs == periodData.durationUs
          && this.adPlaybackState.equals(periodData.adPlaybackState)
          && this.isPlaceholder == periodData.isPlaceholder;
    }

    @Override
    public int hashCode() {
      int result = 7;
      result = 31 * result + uid.hashCode();
      result = 31 * result + (int) (durationUs ^ (durationUs >>> 32));
      result = 31 * result + adPlaybackState.hashCode();
      result = 31 * result + (isPlaceholder ? 1 : 0);
      return result;
    }
  }

  /** A supplier for a position. */
  protected interface PositionSupplier {

    /** An instance returning a constant position of zero. */
    PositionSupplier ZERO = getConstant(/* positionMs= */ 0);

    /**
     * Returns an instance that returns a constant value.
     *
     * @param positionMs The constant position to return, in milliseconds.
     */
    static PositionSupplier getConstant(long positionMs) {
      return () -> positionMs;
    }

    /**
     * Returns an instance that extrapolates the provided position into the future.
     *
     * @param currentPositionMs The current position in milliseconds.
     * @param playbackSpeed The playback speed with which the position is assumed to increase.
     */
    static PositionSupplier getExtrapolating(long currentPositionMs, float playbackSpeed) {
      long startTimeMs = SystemClock.elapsedRealtime();
      return () -> {
        long currentTimeMs = SystemClock.elapsedRealtime();
        return currentPositionMs + (long) ((currentTimeMs - startTimeMs) * playbackSpeed);
      };
    }

    /** Returns the position. */
    long get();
  }

  /**
   * Position difference threshold below which we do not automatically report a position
   * discontinuity, in milliseconds.
   */
  private static final long POSITION_DISCONTINUITY_THRESHOLD_MS = 1000;

  private final ListenerSet<Listener> listeners;
  private final Looper applicationLooper;
  private final HandlerWrapper applicationHandler;
  private final HashSet<ListenableFuture<?>> pendingOperations;
  private final Timeline.Period period;

  private @MonotonicNonNull State state;
  private boolean released;

  /**
   * Creates the base class.
   *
   * @param applicationLooper The {@link Looper} that must be used for all calls to the player and
   *     that is used to call listeners on.
   */
  protected SimpleBasePlayer(Looper applicationLooper) {
    this(applicationLooper, Clock.DEFAULT);
  }

  /**
   * Creates the base class.
   *
   * @param applicationLooper The {@link Looper} that must be used for all calls to the player and
   *     that is used to call listeners on.
   * @param clock The {@link Clock} that will be used by the player.
   */
  protected SimpleBasePlayer(Looper applicationLooper, Clock clock) {
    this.applicationLooper = applicationLooper;
    applicationHandler = clock.createHandler(applicationLooper, /* callback= */ null);
    pendingOperations = new HashSet<>();
    period = new Timeline.Period();
    @SuppressWarnings("nullness:argument.type.incompatible") // Using this in constructor.
    ListenerSet<Player.Listener> listenerSet =
        new ListenerSet<>(
            applicationLooper,
            clock,
            (listener, flags) -> listener.onEvents(/* player= */ this, new Events(flags)));
    listeners = listenerSet;
  }

  @Override
  public final void addListener(Listener listener) {
    // Don't verify application thread. We allow calls to this method from any thread.
    listeners.add(checkNotNull(listener));
  }

  @Override
  public final void removeListener(Listener listener) {
    verifyApplicationThreadAndInitState();
    listeners.remove(listener);
  }

  @Override
  public final Looper getApplicationLooper() {
    // Don't verify application thread. We allow calls to this method from any thread.
    return applicationLooper;
  }

  @Override
  public final Commands getAvailableCommands() {
    verifyApplicationThreadAndInitState();
    return state.availableCommands;
  }

  @Override
  public final void setPlayWhenReady(boolean playWhenReady) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_PLAY_PAUSE)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetPlayWhenReady(playWhenReady),
        /* placeholderStateSupplier= */ () ->
            state
                .buildUpon()
                .setPlayWhenReady(playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
                .build());
  }

  @Override
  public final boolean getPlayWhenReady() {
    verifyApplicationThreadAndInitState();
    return state.playWhenReady;
  }

  @Override
  public final void setMediaItems(List<MediaItem> mediaItems, boolean resetPosition) {
    verifyApplicationThreadAndInitState();
    int startIndex = resetPosition ? C.INDEX_UNSET : state.currentMediaItemIndex;
    long startPositionMs = resetPosition ? C.TIME_UNSET : state.contentPositionMsSupplier.get();
    setMediaItemsInternal(mediaItems, startIndex, startPositionMs);
  }

  @Override
  public final void setMediaItems(
      List<MediaItem> mediaItems, int startIndex, long startPositionMs) {
    verifyApplicationThreadAndInitState();
    if (startIndex == C.INDEX_UNSET) {
      startIndex = state.currentMediaItemIndex;
      startPositionMs = state.contentPositionMsSupplier.get();
    }
    setMediaItemsInternal(mediaItems, startIndex, startPositionMs);
  }

  @RequiresNonNull("state")
  private void setMediaItemsInternal(
      List<MediaItem> mediaItems, int startIndex, long startPositionMs) {
    checkArgument(startIndex == C.INDEX_UNSET || startIndex >= 0);
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS)
        && (mediaItems.size() != 1 || !shouldHandleCommand(Player.COMMAND_SET_MEDIA_ITEM))) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetMediaItems(mediaItems, startIndex, startPositionMs),
        /* placeholderStateSupplier= */ () -> {
          ArrayList<MediaItemData> placeholderPlaylist = new ArrayList<>();
          for (int i = 0; i < mediaItems.size(); i++) {
            placeholderPlaylist.add(getPlaceholderMediaItemData(mediaItems.get(i)));
          }
          return getStateWithNewPlaylistAndPosition(
              state, placeholderPlaylist, startIndex, startPositionMs);
        });
  }

  @Override
  public final void addMediaItems(int index, List<MediaItem> mediaItems) {
    verifyApplicationThreadAndInitState();
    checkArgument(index >= 0);
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    int playlistSize = state.playlist.size();
    if (!shouldHandleCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS) || mediaItems.isEmpty()) {
      return;
    }
    int correctedIndex = min(index, playlistSize);
    updateStateForPendingOperation(
        /* pendingOperation= */ handleAddMediaItems(correctedIndex, mediaItems),
        /* placeholderStateSupplier= */ () -> {
          ArrayList<MediaItemData> placeholderPlaylist = new ArrayList<>(state.playlist);
          for (int i = 0; i < mediaItems.size(); i++) {
            placeholderPlaylist.add(
                i + correctedIndex, getPlaceholderMediaItemData(mediaItems.get(i)));
          }
          return getStateWithNewPlaylist(state, placeholderPlaylist, period);
        });
  }

  @Override
  public final void moveMediaItems(int fromIndex, int toIndex, int newIndex) {
    verifyApplicationThreadAndInitState();
    checkArgument(fromIndex >= 0 && toIndex >= fromIndex && newIndex >= 0);
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    int playlistSize = state.playlist.size();
    if (!shouldHandleCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS)
        || playlistSize == 0
        || fromIndex >= playlistSize) {
      return;
    }
    int correctedToIndex = min(toIndex, playlistSize);
    int correctedNewIndex = min(newIndex, state.playlist.size() - (correctedToIndex - fromIndex));
    if (fromIndex == correctedToIndex || correctedNewIndex == fromIndex) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleMoveMediaItems(
            fromIndex, correctedToIndex, correctedNewIndex),
        /* placeholderStateSupplier= */ () -> {
          ArrayList<MediaItemData> placeholderPlaylist = new ArrayList<>(state.playlist);
          Util.moveItems(placeholderPlaylist, fromIndex, correctedToIndex, correctedNewIndex);
          return getStateWithNewPlaylist(state, placeholderPlaylist, period);
        });
  }

  @Override
  public final void removeMediaItems(int fromIndex, int toIndex) {
    verifyApplicationThreadAndInitState();
    checkArgument(fromIndex >= 0 && toIndex >= fromIndex);
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    int playlistSize = state.playlist.size();
    if (!shouldHandleCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS)
        || playlistSize == 0
        || fromIndex >= playlistSize) {
      return;
    }
    int correctedToIndex = min(toIndex, playlistSize);
    if (fromIndex == correctedToIndex) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleRemoveMediaItems(fromIndex, correctedToIndex),
        /* placeholderStateSupplier= */ () -> {
          ArrayList<MediaItemData> placeholderPlaylist = new ArrayList<>(state.playlist);
          Util.removeRange(placeholderPlaylist, fromIndex, correctedToIndex);
          return getStateWithNewPlaylist(state, placeholderPlaylist, period);
        });
  }

  @Override
  public final void prepare() {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_PREPARE)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handlePrepare(),
        /* placeholderStateSupplier= */ () ->
            state
                .buildUpon()
                .setPlayerError(null)
                .setPlaybackState(state.timeline.isEmpty() ? STATE_ENDED : STATE_BUFFERING)
                .build());
  }

  @Override
  @Player.State
  public final int getPlaybackState() {
    verifyApplicationThreadAndInitState();
    return state.playbackState;
  }

  @Override
  public final int getPlaybackSuppressionReason() {
    verifyApplicationThreadAndInitState();
    return state.playbackSuppressionReason;
  }

  @Nullable
  @Override
  public final PlaybackException getPlayerError() {
    verifyApplicationThreadAndInitState();
    return state.playerError;
  }

  @Override
  public final void setRepeatMode(@Player.RepeatMode int repeatMode) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_REPEAT_MODE)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetRepeatMode(repeatMode),
        /* placeholderStateSupplier= */ () -> state.buildUpon().setRepeatMode(repeatMode).build());
  }

  @Override
  @Player.RepeatMode
  public final int getRepeatMode() {
    verifyApplicationThreadAndInitState();
    return state.repeatMode;
  }

  @Override
  public final void setShuffleModeEnabled(boolean shuffleModeEnabled) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_SHUFFLE_MODE)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetShuffleModeEnabled(shuffleModeEnabled),
        /* placeholderStateSupplier= */ () ->
            state.buildUpon().setShuffleModeEnabled(shuffleModeEnabled).build());
  }

  @Override
  public final boolean getShuffleModeEnabled() {
    verifyApplicationThreadAndInitState();
    return state.shuffleModeEnabled;
  }

  @Override
  public final boolean isLoading() {
    verifyApplicationThreadAndInitState();
    return state.isLoading;
  }

  @Override
  @VisibleForTesting(otherwise = PROTECTED)
  public final void seekTo(
      int mediaItemIndex,
      long positionMs,
      @Player.Command int seekCommand,
      boolean isRepeatingCurrentItem) {
    verifyApplicationThreadAndInitState();
    checkArgument(mediaItemIndex >= 0);
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(seekCommand)
        || isPlayingAd()
        || (!state.playlist.isEmpty() && mediaItemIndex >= state.playlist.size())) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSeek(mediaItemIndex, positionMs, seekCommand),
        /* placeholderStateSupplier= */ () ->
            getStateWithNewPlaylistAndPosition(state, state.playlist, mediaItemIndex, positionMs),
        /* seeked= */ true,
        isRepeatingCurrentItem);
  }

  @Override
  public final long getSeekBackIncrement() {
    verifyApplicationThreadAndInitState();
    return state.seekBackIncrementMs;
  }

  @Override
  public final long getSeekForwardIncrement() {
    verifyApplicationThreadAndInitState();
    return state.seekForwardIncrementMs;
  }

  @Override
  public final long getMaxSeekToPreviousPosition() {
    verifyApplicationThreadAndInitState();
    return state.maxSeekToPreviousPositionMs;
  }

  @Override
  public final void setPlaybackParameters(PlaybackParameters playbackParameters) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_SPEED_AND_PITCH)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetPlaybackParameters(playbackParameters),
        /* placeholderStateSupplier= */ () ->
            state.buildUpon().setPlaybackParameters(playbackParameters).build());
  }

  @Override
  public final PlaybackParameters getPlaybackParameters() {
    verifyApplicationThreadAndInitState();
    return state.playbackParameters;
  }

  @Override
  public final void stop() {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_STOP)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleStop(),
        /* placeholderStateSupplier= */ () ->
            state
                .buildUpon()
                .setPlaybackState(Player.STATE_IDLE)
                .setTotalBufferedDurationMs(PositionSupplier.ZERO)
                .setContentBufferedPositionMs(
                    PositionSupplier.getConstant(getContentPositionMsInternal(state)))
                .setAdBufferedPositionMs(state.adPositionMsSupplier)
                .setIsLoading(false)
                .build());
  }

  @Override
  public final void stop(boolean reset) {
    stop();
    if (reset) {
      clearMediaItems();
    }
  }

  @Override
  public final void release() {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (released) { // TODO(b/261158047): Replace by !shouldHandleCommand(Player.COMMAND_RELEASE)
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleRelease(), /* placeholderStateSupplier= */ () -> state);
    released = true;
    listeners.release();
    // Enforce some final state values in case getters are called after release.
    this.state =
        this.state
            .buildUpon()
            .setPlaybackState(Player.STATE_IDLE)
            .setTotalBufferedDurationMs(PositionSupplier.ZERO)
            .setContentBufferedPositionMs(
                PositionSupplier.getConstant(getContentPositionMsInternal(state)))
            .setAdBufferedPositionMs(state.adPositionMsSupplier)
            .setIsLoading(false)
            .build();
  }

  @Override
  public final Tracks getCurrentTracks() {
    verifyApplicationThreadAndInitState();
    return getCurrentTracksInternal(state);
  }

  @Override
  public final TrackSelectionParameters getTrackSelectionParameters() {
    verifyApplicationThreadAndInitState();
    return state.trackSelectionParameters;
  }

  @Override
  public final void setTrackSelectionParameters(TrackSelectionParameters parameters) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetTrackSelectionParameters(parameters),
        /* placeholderStateSupplier= */ () ->
            state.buildUpon().setTrackSelectionParameters(parameters).build());
  }

  @Override
  public final MediaMetadata getMediaMetadata() {
    verifyApplicationThreadAndInitState();
    return getMediaMetadataInternal(state);
  }

  @Override
  public final MediaMetadata getPlaylistMetadata() {
    verifyApplicationThreadAndInitState();
    return state.playlistMetadata;
  }

  @Override
  public final void setPlaylistMetadata(MediaMetadata mediaMetadata) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_MEDIA_ITEMS_METADATA)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetPlaylistMetadata(mediaMetadata),
        /* placeholderStateSupplier= */ () ->
            state.buildUpon().setPlaylistMetadata(mediaMetadata).build());
  }

  @Override
  public final Timeline getCurrentTimeline() {
    verifyApplicationThreadAndInitState();
    return state.timeline;
  }

  @Override
  public final int getCurrentPeriodIndex() {
    verifyApplicationThreadAndInitState();
    return getCurrentPeriodIndexInternal(state, window, period);
  }

  @Override
  public final int getCurrentMediaItemIndex() {
    verifyApplicationThreadAndInitState();
    return getCurrentMediaItemIndexInternal(state);
  }

  @Override
  public final long getDuration() {
    verifyApplicationThreadAndInitState();
    if (isPlayingAd()) {
      state.timeline.getPeriod(getCurrentPeriodIndex(), period);
      long adDurationUs =
          period.getAdDurationUs(state.currentAdGroupIndex, state.currentAdIndexInAdGroup);
      return Util.usToMs(adDurationUs);
    }
    return getContentDuration();
  }

  @Override
  public final long getCurrentPosition() {
    verifyApplicationThreadAndInitState();
    return isPlayingAd() ? state.adPositionMsSupplier.get() : getContentPosition();
  }

  @Override
  public final long getBufferedPosition() {
    verifyApplicationThreadAndInitState();
    return isPlayingAd()
        ? max(state.adBufferedPositionMsSupplier.get(), state.adPositionMsSupplier.get())
        : getContentBufferedPosition();
  }

  @Override
  public final long getTotalBufferedDuration() {
    verifyApplicationThreadAndInitState();
    return state.totalBufferedDurationMsSupplier.get();
  }

  @Override
  public final boolean isPlayingAd() {
    verifyApplicationThreadAndInitState();
    return state.currentAdGroupIndex != C.INDEX_UNSET;
  }

  @Override
  public final int getCurrentAdGroupIndex() {
    verifyApplicationThreadAndInitState();
    return state.currentAdGroupIndex;
  }

  @Override
  public final int getCurrentAdIndexInAdGroup() {
    verifyApplicationThreadAndInitState();
    return state.currentAdIndexInAdGroup;
  }

  @Override
  public final long getContentPosition() {
    verifyApplicationThreadAndInitState();
    return getContentPositionMsInternal(state);
  }

  @Override
  public final long getContentBufferedPosition() {
    verifyApplicationThreadAndInitState();
    return max(getContentBufferedPositionMsInternal(state), getContentPositionMsInternal(state));
  }

  @Override
  public final AudioAttributes getAudioAttributes() {
    verifyApplicationThreadAndInitState();
    return state.audioAttributes;
  }

  @Override
  public final void setVolume(float volume) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_VOLUME)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetVolume(volume),
        /* placeholderStateSupplier= */ () -> state.buildUpon().setVolume(volume).build());
  }

  @Override
  public final float getVolume() {
    verifyApplicationThreadAndInitState();
    return state.volume;
  }

  @Override
  public final void setVideoSurface(@Nullable Surface surface) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) {
      return;
    }
    if (surface == null) {
      clearVideoSurface();
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetVideoOutput(surface),
        /* placeholderStateSupplier= */ () ->
            state.buildUpon().setSurfaceSize(Size.UNKNOWN).build());
  }

  @Override
  public final void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) {
      return;
    }
    if (surfaceHolder == null) {
      clearVideoSurface();
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetVideoOutput(surfaceHolder),
        /* placeholderStateSupplier= */ () ->
            state.buildUpon().setSurfaceSize(getSurfaceHolderSize(surfaceHolder)).build());
  }

  @Override
  public final void setVideoSurfaceView(@Nullable SurfaceView surfaceView) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) {
      return;
    }
    if (surfaceView == null) {
      clearVideoSurface();
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetVideoOutput(surfaceView),
        /* placeholderStateSupplier= */ () ->
            state
                .buildUpon()
                .setSurfaceSize(getSurfaceHolderSize(surfaceView.getHolder()))
                .build());
  }

  @Override
  public final void setVideoTextureView(@Nullable TextureView textureView) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) {
      return;
    }
    if (textureView == null) {
      clearVideoSurface();
      return;
    }
    Size surfaceSize;
    if (textureView.isAvailable()) {
      surfaceSize = new Size(textureView.getWidth(), textureView.getHeight());
    } else {
      surfaceSize = Size.ZERO;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetVideoOutput(textureView),
        /* placeholderStateSupplier= */ () ->
            state.buildUpon().setSurfaceSize(surfaceSize).build());
  }

  @Override
  public final void clearVideoSurface() {
    clearVideoOutput(/* videoOutput= */ null);
  }

  @Override
  public final void clearVideoSurface(@Nullable Surface surface) {
    clearVideoOutput(surface);
  }

  @Override
  public final void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {
    clearVideoOutput(surfaceHolder);
  }

  @Override
  public final void clearVideoSurfaceView(@Nullable SurfaceView surfaceView) {
    clearVideoOutput(surfaceView);
  }

  @Override
  public final void clearVideoTextureView(@Nullable TextureView textureView) {
    clearVideoOutput(textureView);
  }

  private void clearVideoOutput(@Nullable Object videoOutput) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleClearVideoOutput(videoOutput),
        /* placeholderStateSupplier= */ () -> state.buildUpon().setSurfaceSize(Size.ZERO).build());
  }

  @Override
  public final VideoSize getVideoSize() {
    verifyApplicationThreadAndInitState();
    return state.videoSize;
  }

  @Override
  public final Size getSurfaceSize() {
    verifyApplicationThreadAndInitState();
    return state.surfaceSize;
  }

  @Override
  public final CueGroup getCurrentCues() {
    verifyApplicationThreadAndInitState();
    return state.currentCues;
  }

  @Override
  public final DeviceInfo getDeviceInfo() {
    verifyApplicationThreadAndInitState();
    return state.deviceInfo;
  }

  @Override
  public final int getDeviceVolume() {
    verifyApplicationThreadAndInitState();
    return state.deviceVolume;
  }

  @Override
  public final boolean isDeviceMuted() {
    verifyApplicationThreadAndInitState();
    return state.isDeviceMuted;
  }

  @Override
  public final void setDeviceVolume(int volume) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_SET_DEVICE_VOLUME)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetDeviceVolume(volume),
        /* placeholderStateSupplier= */ () -> state.buildUpon().setDeviceVolume(volume).build());
  }

  @Override
  public final void increaseDeviceVolume() {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_ADJUST_DEVICE_VOLUME)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleIncreaseDeviceVolume(),
        /* placeholderStateSupplier= */ () ->
            state.buildUpon().setDeviceVolume(state.deviceVolume + 1).build());
  }

  @Override
  public final void decreaseDeviceVolume() {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_ADJUST_DEVICE_VOLUME)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleDecreaseDeviceVolume(),
        /* placeholderStateSupplier= */ () ->
            state.buildUpon().setDeviceVolume(max(0, state.deviceVolume - 1)).build());
  }

  @Override
  public final void setDeviceMuted(boolean muted) {
    verifyApplicationThreadAndInitState();
    // Use a local copy to ensure the lambda below uses the current state value.
    State state = this.state;
    if (!shouldHandleCommand(Player.COMMAND_ADJUST_DEVICE_VOLUME)) {
      return;
    }
    updateStateForPendingOperation(
        /* pendingOperation= */ handleSetDeviceMuted(muted),
        /* placeholderStateSupplier= */ () -> state.buildUpon().setIsDeviceMuted(muted).build());
  }

  /**
   * Invalidates the current state.
   *
   * <p>Triggers a call to {@link #getState()} and informs listeners if the state changed.
   *
   * <p>Note that this may not have an immediate effect while there are still player methods being
   * handled asynchronously. The state will be invalidated automatically once these pending
   * synchronous operations are finished and there is no need to call this method again.
   */
  protected final void invalidateState() {
    verifyApplicationThreadAndInitState();
    if (!pendingOperations.isEmpty() || released) {
      return;
    }
    updateStateAndInformListeners(
        getState(), /* seeked= */ false, /* isRepeatingCurrentItem= */ false);
  }

  /**
   * Returns the current {@link State} of the player.
   *
   * <p>The {@link State} should include all {@linkplain
   * State.Builder#setAvailableCommands(Commands) available commands} indicating which player
   * methods are allowed to be called.
   *
   * <p>Note that this method won't be called while asynchronous handling of player methods is in
   * progress. This means that the implementation doesn't need to handle state changes caused by
   * these asynchronous operations until they are done and can return the currently known state
   * directly. The placeholder state used while these asynchronous operations are in progress can be
   * customized by overriding {@link #getPlaceholderState(State)} if required.
   */
  @ForOverride
  protected abstract State getState();

  /**
   * Returns the placeholder state used while a player method is handled asynchronously.
   *
   * <p>The {@code suggestedPlaceholderState} already contains the most likely state update, for
   * example setting {@link State#playWhenReady} to true if {@code player.setPlayWhenReady(true)} is
   * called, and an implementations only needs to override this method if it can determine a more
   * accurate placeholder state.
   *
   * @param suggestedPlaceholderState The suggested placeholder {@link State}, including the most
   *     likely outcome of handling all pending asynchronous operations.
   * @return The placeholder {@link State} to use while asynchronous operations are pending.
   */
  @ForOverride
  protected State getPlaceholderState(State suggestedPlaceholderState) {
    return suggestedPlaceholderState;
  }

  /**
   * Returns the placeholder {@link MediaItemData} used for a new {@link MediaItem} added to the
   * playlist.
   *
   * <p>An implementation only needs to override this method if it can determine a more accurate
   * placeholder state than the default.
   *
   * @param mediaItem The {@link MediaItem} added to the playlist.
   * @return The {@link MediaItemData} used as placeholder while adding the item to the playlist is
   *     in progress.
   */
  @ForOverride
  protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) {
    return new MediaItemData.Builder(new PlaceholderUid())
        .setMediaItem(mediaItem)
        .setIsDynamic(true)
        .setIsPlaceholder(true)
        .build();
  }

  /**
   * Handles calls to {@link Player#setPlayWhenReady}, {@link Player#play} and {@link Player#pause}.
   *
   * <p>Will only be called if {@link Player#COMMAND_PLAY_PAUSE} is available.
   *
   * @param playWhenReady The requested {@link State#playWhenReady}
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetPlayWhenReady(boolean playWhenReady) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_PLAY_PAUSE");
  }

  /**
   * Handles calls to {@link Player#prepare}.
   *
   * <p>Will only be called if {@link Player#COMMAND_PREPARE} is available.
   *
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handlePrepare() {
    throw new IllegalStateException("Missing implementation to handle COMMAND_PREPARE");
  }

  /**
   * Handles calls to {@link Player#stop}.
   *
   * <p>Will only be called if {@link Player#COMMAND_STOP} is available.
   *
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleStop() {
    throw new IllegalStateException("Missing implementation to handle COMMAND_STOP");
  }

  /**
   * Handles calls to {@link Player#release}.
   *
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  // TODO(b/261158047): Add that this method will only be called if COMMAND_RELEASE is available.
  @ForOverride
  protected ListenableFuture<?> handleRelease() {
    throw new IllegalStateException("Missing implementation to handle COMMAND_RELEASE");
  }

  /**
   * Handles calls to {@link Player#setRepeatMode}.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_REPEAT_MODE} is available.
   *
   * @param repeatMode The requested {@link RepeatMode}.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetRepeatMode(@RepeatMode int repeatMode) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_SET_REPEAT_MODE");
  }

  /**
   * Handles calls to {@link Player#setShuffleModeEnabled}.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_SHUFFLE_MODE} is available.
   *
   * @param shuffleModeEnabled Whether shuffle mode was requested to be enabled.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetShuffleModeEnabled(boolean shuffleModeEnabled) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_SET_SHUFFLE_MODE");
  }

  /**
   * Handles calls to {@link Player#setPlaybackParameters} or {@link Player#setPlaybackSpeed}.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_SPEED_AND_PITCH} is available.
   *
   * @param playbackParameters The requested {@link PlaybackParameters}.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetPlaybackParameters(PlaybackParameters playbackParameters) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_SET_SPEED_AND_PITCH");
  }

  /**
   * Handles calls to {@link Player#setTrackSelectionParameters}.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_TRACK_SELECTION_PARAMETERS} is available.
   *
   * @param trackSelectionParameters The requested {@link TrackSelectionParameters}.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetTrackSelectionParameters(
      TrackSelectionParameters trackSelectionParameters) {
    throw new IllegalStateException(
        "Missing implementation to handle COMMAND_SET_TRACK_SELECTION_PARAMETERS");
  }

  /**
   * Handles calls to {@link Player#setPlaylistMetadata}.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_MEDIA_ITEMS_METADATA} is available.
   *
   * @param playlistMetadata The requested {@linkplain MediaMetadata playlist metadata}.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetPlaylistMetadata(MediaMetadata playlistMetadata) {
    throw new IllegalStateException(
        "Missing implementation to handle COMMAND_SET_MEDIA_ITEMS_METADATA");
  }

  /**
   * Handles calls to {@link Player#setVolume}.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_VOLUME} is available.
   *
   * @param volume The requested audio volume, with 0 being silence and 1 being unity gain (signal
   *     unchanged).
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetVolume(@FloatRange(from = 0, to = 1.0) float volume) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_SET_VOLUME");
  }

  /**
   * Handles calls to {@link Player#setDeviceVolume}.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_DEVICE_VOLUME} is available.
   *
   * @param deviceVolume The requested device volume.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetDeviceVolume(@IntRange(from = 0) int deviceVolume) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_SET_DEVICE_VOLUME");
  }

  /**
   * Handles calls to {@link Player#increaseDeviceVolume()}.
   *
   * <p>Will only be called if {@link Player#COMMAND_ADJUST_DEVICE_VOLUME} is available.
   *
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleIncreaseDeviceVolume() {
    throw new IllegalStateException(
        "Missing implementation to handle COMMAND_ADJUST_DEVICE_VOLUME");
  }

  /**
   * Handles calls to {@link Player#decreaseDeviceVolume()}.
   *
   * <p>Will only be called if {@link Player#COMMAND_ADJUST_DEVICE_VOLUME} is available.
   *
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleDecreaseDeviceVolume() {
    throw new IllegalStateException(
        "Missing implementation to handle COMMAND_ADJUST_DEVICE_VOLUME");
  }

  /**
   * Handles calls to {@link Player#setDeviceMuted}.
   *
   * <p>Will only be called if {@link Player#COMMAND_ADJUST_DEVICE_VOLUME} is available.
   *
   * @param muted Whether the device was requested to be muted.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetDeviceMuted(boolean muted) {
    throw new IllegalStateException(
        "Missing implementation to handle COMMAND_ADJUST_DEVICE_VOLUME");
  }

  /**
   * Handles calls to set the video output.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_VIDEO_SURFACE} is available.
   *
   * @param videoOutput The requested video output. This is either a {@link Surface}, {@link
   *     SurfaceHolder}, {@link TextureView} or {@link SurfaceView}.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetVideoOutput(Object videoOutput) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_SET_VIDEO_SURFACE");
  }

  /**
   * Handles calls to clear the video output.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_VIDEO_SURFACE} is available.
   *
   * @param videoOutput The video output to clear. If null any current output should be cleared. If
   *     non-null, the output should only be cleared if it matches the provided argument. This is
   *     either a {@link Surface}, {@link SurfaceHolder}, {@link TextureView} or {@link
   *     SurfaceView}.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleClearVideoOutput(@Nullable Object videoOutput) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_SET_VIDEO_SURFACE");
  }

  /**
   * Handles calls to {@link Player#setMediaItem} and {@link Player#setMediaItems}.
   *
   * <p>Will only be called if {@link Player#COMMAND_SET_MEDIA_ITEM} or {@link
   * Player#COMMAND_CHANGE_MEDIA_ITEMS} is available. If only {@link Player#COMMAND_SET_MEDIA_ITEM}
   * is available, the list of media items will always contain exactly one item.
   *
   * @param mediaItems The media items to add.
   * @param startIndex The index at which to start playback from, or {@link C#INDEX_UNSET} to start
   *     at the default item.
   * @param startPositionMs The position in milliseconds to start playback from, or {@link
   *     C#TIME_UNSET} to start at the default position in the media item.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSetMediaItems(
      List<MediaItem> mediaItems, int startIndex, long startPositionMs) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_SET_MEDIA_ITEM(S)");
  }

  /**
   * Handles calls to {@link Player#addMediaItem} and {@link Player#addMediaItems}.
   *
   * <p>Will only be called if {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} is available.
   *
   * @param index The index at which to add the items. The index is in the range 0 &lt;= {@code
   *     index} &lt;= {@link #getMediaItemCount()}.
   * @param mediaItems The media items to add.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleAddMediaItems(int index, List<MediaItem> mediaItems) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_CHANGE_MEDIA_ITEMS");
  }

  /**
   * Handles calls to {@link Player#moveMediaItem} and {@link Player#moveMediaItems}.
   *
   * <p>Will only be called if {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} is available.
   *
   * @param fromIndex The start index of the items to move. The index is in the range 0 &lt;= {@code
   *     fromIndex} &lt; {@link #getMediaItemCount()}.
   * @param toIndex The index of the first item not to be included in the move (exclusive). The
   *     index is in the range {@code fromIndex} &lt; {@code toIndex} &lt;= {@link
   *     #getMediaItemCount()}.
   * @param newIndex The new index of the first moved item. The index is in the range {@code 0}
   *     &lt;= {@code newIndex} &lt; {@link #getMediaItemCount() - (toIndex - fromIndex)}.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleMoveMediaItems(int fromIndex, int toIndex, int newIndex) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_CHANGE_MEDIA_ITEMS");
  }

  /**
   * Handles calls to {@link Player#removeMediaItem} and {@link Player#removeMediaItems}.
   *
   * <p>Will only be called if {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} is available.
   *
   * @param fromIndex The index at which to start removing media items. The index is in the range 0
   *     &lt;= {@code fromIndex} &lt; {@link #getMediaItemCount()}.
   * @param toIndex The index of the first item to be kept (exclusive). The index is in the range
   *     {@code fromIndex} &lt; {@code toIndex} &lt;= {@link #getMediaItemCount()}.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleRemoveMediaItems(int fromIndex, int toIndex) {
    throw new IllegalStateException("Missing implementation to handle COMMAND_CHANGE_MEDIA_ITEMS");
  }

  /**
   * Handles calls to {@link Player#seekTo} and other seek operations (for example, {@link
   * Player#seekToNext}).
   *
   * <p>Will only be called if the appropriate {@link Player.Command}, for example {@link
   * Player#COMMAND_SEEK_TO_MEDIA_ITEM} or {@link Player#COMMAND_SEEK_TO_NEXT}, is available.
   *
   * @param mediaItemIndex The media item index to seek to. The index is in the range 0 &lt;= {@code
   *     mediaItemIndex} &lt; {@code mediaItems.size()}.
   * @param positionMs The position in milliseconds to start playback from, or {@link C#TIME_UNSET}
   *     to start at the default position in the media item.
   * @param seekCommand The {@link Player.Command} used to trigger the seek.
   * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
   *     changes caused by this call.
   */
  @ForOverride
  protected ListenableFuture<?> handleSeek(
      int mediaItemIndex, long positionMs, @Player.Command int seekCommand) {
    throw new IllegalStateException("Missing implementation to handle one of the COMMAND_SEEK_*");
  }

  @RequiresNonNull("state")
  private boolean shouldHandleCommand(@Player.Command int commandCode) {
    return !released && state.availableCommands.contains(commandCode);
  }

  @SuppressWarnings("deprecation") // Calling deprecated listener methods.
  @RequiresNonNull("state")
  private void updateStateAndInformListeners(
      State newState, boolean seeked, boolean isRepeatingCurrentItem) {
    State previousState = state;
    // Assign new state immediately such that all getters return the right values, but use a
    // snapshot of the previous and new state so that listener invocations are triggered correctly.
    this.state = newState;
    if (newState.hasPositionDiscontinuity || newState.newlyRenderedFirstFrame) {
      // Clear one-time events to avoid signalling them again later.
      this.state =
          this.state
              .buildUpon()
              .clearPositionDiscontinuity()
              .setNewlyRenderedFirstFrame(false)
              .build();
    }

    boolean playWhenReadyChanged = previousState.playWhenReady != newState.playWhenReady;
    boolean playbackStateChanged = previousState.playbackState != newState.playbackState;
    Tracks previousTracks = getCurrentTracksInternal(previousState);
    Tracks newTracks = getCurrentTracksInternal(newState);
    MediaMetadata previousMediaMetadata = getMediaMetadataInternal(previousState);
    MediaMetadata newMediaMetadata = getMediaMetadataInternal(newState);
    int positionDiscontinuityReason =
        getPositionDiscontinuityReason(previousState, newState, seeked, window, period);
    boolean timelineChanged = !previousState.timeline.equals(newState.timeline);
    int mediaItemTransitionReason =
        getMediaItemTransitionReason(
            previousState, newState, positionDiscontinuityReason, isRepeatingCurrentItem, window);

    if (timelineChanged) {
      @Player.TimelineChangeReason
      int timelineChangeReason = getTimelineChangeReason(previousState.playlist, newState.playlist);
      listeners.queueEvent(
          Player.EVENT_TIMELINE_CHANGED,
          listener -> listener.onTimelineChanged(newState.timeline, timelineChangeReason));
    }
    if (positionDiscontinuityReason != C.INDEX_UNSET) {
      PositionInfo previousPositionInfo =
          getPositionInfo(previousState, /* useDiscontinuityPosition= */ false, window, period);
      PositionInfo positionInfo =
          getPositionInfo(
              newState,
              /* useDiscontinuityPosition= */ newState.hasPositionDiscontinuity,
              window,
              period);
      listeners.queueEvent(
          Player.EVENT_POSITION_DISCONTINUITY,
          listener -> {
            listener.onPositionDiscontinuity(positionDiscontinuityReason);
            listener.onPositionDiscontinuity(
                previousPositionInfo, positionInfo, positionDiscontinuityReason);
          });
    }
    if (mediaItemTransitionReason != C.INDEX_UNSET) {
      @Nullable
      MediaItem mediaItem =
          newState.timeline.isEmpty()
              ? null
              : newState.playlist.get(getCurrentMediaItemIndexInternal(newState)).mediaItem;
      listeners.queueEvent(
          Player.EVENT_MEDIA_ITEM_TRANSITION,
          listener -> listener.onMediaItemTransition(mediaItem, mediaItemTransitionReason));
    }
    if (!Util.areEqual(previousState.playerError, newState.playerError)) {
      listeners.queueEvent(
          Player.EVENT_PLAYER_ERROR,
          listener -> listener.onPlayerErrorChanged(newState.playerError));
      if (newState.playerError != null) {
        listeners.queueEvent(
            Player.EVENT_PLAYER_ERROR,
            listener -> listener.onPlayerError(castNonNull(newState.playerError)));
      }
    }
    if (!previousState.trackSelectionParameters.equals(newState.trackSelectionParameters)) {
      listeners.queueEvent(
          Player.EVENT_TRACK_SELECTION_PARAMETERS_CHANGED,
          listener ->
              listener.onTrackSelectionParametersChanged(newState.trackSelectionParameters));
    }
    if (!previousTracks.equals(newTracks)) {
      listeners.queueEvent(
          Player.EVENT_TRACKS_CHANGED, listener -> listener.onTracksChanged(newTracks));
    }
    if (!previousMediaMetadata.equals(newMediaMetadata)) {
      listeners.queueEvent(
          EVENT_MEDIA_METADATA_CHANGED,
          listener -> listener.onMediaMetadataChanged(newMediaMetadata));
    }
    if (previousState.isLoading != newState.isLoading) {
      listeners.queueEvent(
          Player.EVENT_IS_LOADING_CHANGED,
          listener -> {
            listener.onLoadingChanged(newState.isLoading);
            listener.onIsLoadingChanged(newState.isLoading);
          });
    }
    if (playWhenReadyChanged || playbackStateChanged) {
      listeners.queueEvent(
          /* eventFlag= */ C.INDEX_UNSET,
          listener ->
              listener.onPlayerStateChanged(newState.playWhenReady, newState.playbackState));
    }
    if (playbackStateChanged) {
      listeners.queueEvent(
          Player.EVENT_PLAYBACK_STATE_CHANGED,
          listener -> listener.onPlaybackStateChanged(newState.playbackState));
    }
    if (playWhenReadyChanged
        || previousState.playWhenReadyChangeReason != newState.playWhenReadyChangeReason) {
      listeners.queueEvent(
          Player.EVENT_PLAY_WHEN_READY_CHANGED,
          listener ->
              listener.onPlayWhenReadyChanged(
                  newState.playWhenReady, newState.playWhenReadyChangeReason));
    }
    if (previousState.playbackSuppressionReason != newState.playbackSuppressionReason) {
      listeners.queueEvent(
          Player.EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED,
          listener ->
              listener.onPlaybackSuppressionReasonChanged(newState.playbackSuppressionReason));
    }
    if (isPlaying(previousState) != isPlaying(newState)) {
      listeners.queueEvent(
          Player.EVENT_IS_PLAYING_CHANGED,
          listener -> listener.onIsPlayingChanged(isPlaying(newState)));
    }
    if (!previousState.playbackParameters.equals(newState.playbackParameters)) {
      listeners.queueEvent(
          Player.EVENT_PLAYBACK_PARAMETERS_CHANGED,
          listener -> listener.onPlaybackParametersChanged(newState.playbackParameters));
    }
    if (previousState.repeatMode != newState.repeatMode) {
      listeners.queueEvent(
          Player.EVENT_REPEAT_MODE_CHANGED,
          listener -> listener.onRepeatModeChanged(newState.repeatMode));
    }
    if (previousState.shuffleModeEnabled != newState.shuffleModeEnabled) {
      listeners.queueEvent(
          Player.EVENT_SHUFFLE_MODE_ENABLED_CHANGED,
          listener -> listener.onShuffleModeEnabledChanged(newState.shuffleModeEnabled));
    }
    if (previousState.seekBackIncrementMs != newState.seekBackIncrementMs) {
      listeners.queueEvent(
          Player.EVENT_SEEK_BACK_INCREMENT_CHANGED,
          listener -> listener.onSeekBackIncrementChanged(newState.seekBackIncrementMs));
    }
    if (previousState.seekForwardIncrementMs != newState.seekForwardIncrementMs) {
      listeners.queueEvent(
          Player.EVENT_SEEK_FORWARD_INCREMENT_CHANGED,
          listener -> listener.onSeekForwardIncrementChanged(newState.seekForwardIncrementMs));
    }
    if (previousState.maxSeekToPreviousPositionMs != newState.maxSeekToPreviousPositionMs) {
      listeners.queueEvent(
          Player.EVENT_MAX_SEEK_TO_PREVIOUS_POSITION_CHANGED,
          listener ->
              listener.onMaxSeekToPreviousPositionChanged(newState.maxSeekToPreviousPositionMs));
    }
    if (!previousState.audioAttributes.equals(newState.audioAttributes)) {
      listeners.queueEvent(
          Player.EVENT_AUDIO_ATTRIBUTES_CHANGED,
          listener -> listener.onAudioAttributesChanged(newState.audioAttributes));
    }
    if (!previousState.videoSize.equals(newState.videoSize)) {
      listeners.queueEvent(
          Player.EVENT_VIDEO_SIZE_CHANGED,
          listener -> listener.onVideoSizeChanged(newState.videoSize));
    }
    if (!previousState.deviceInfo.equals(newState.deviceInfo)) {
      listeners.queueEvent(
          Player.EVENT_DEVICE_INFO_CHANGED,
          listener -> listener.onDeviceInfoChanged(newState.deviceInfo));
    }
    if (!previousState.playlistMetadata.equals(newState.playlistMetadata)) {
      listeners.queueEvent(
          Player.EVENT_PLAYLIST_METADATA_CHANGED,
          listener -> listener.onPlaylistMetadataChanged(newState.playlistMetadata));
    }
    if (newState.newlyRenderedFirstFrame) {
      listeners.queueEvent(Player.EVENT_RENDERED_FIRST_FRAME, Listener::onRenderedFirstFrame);
    }
    if (!previousState.surfaceSize.equals(newState.surfaceSize)) {
      listeners.queueEvent(
          Player.EVENT_SURFACE_SIZE_CHANGED,
          listener ->
              listener.onSurfaceSizeChanged(
                  newState.surfaceSize.getWidth(), newState.surfaceSize.getHeight()));
    }
    if (previousState.volume != newState.volume) {
      listeners.queueEvent(
          Player.EVENT_VOLUME_CHANGED, listener -> listener.onVolumeChanged(newState.volume));
    }
    if (previousState.deviceVolume != newState.deviceVolume
        || previousState.isDeviceMuted != newState.isDeviceMuted) {
      listeners.queueEvent(
          Player.EVENT_DEVICE_VOLUME_CHANGED,
          listener ->
              listener.onDeviceVolumeChanged(newState.deviceVolume, newState.isDeviceMuted));
    }
    if (!previousState.currentCues.equals(newState.currentCues)) {
      listeners.queueEvent(
          Player.EVENT_CUES,
          listener -> {
            listener.onCues(newState.currentCues.cues);
            listener.onCues(newState.currentCues);
          });
    }
    if (!previousState.timedMetadata.equals(newState.timedMetadata)
        && newState.timedMetadata.presentationTimeUs != C.TIME_UNSET) {
      listeners.queueEvent(
          Player.EVENT_METADATA, listener -> listener.onMetadata(newState.timedMetadata));
    }
    if (positionDiscontinuityReason == Player.DISCONTINUITY_REASON_SEEK) {
      listeners.queueEvent(/* eventFlag= */ C.INDEX_UNSET, Listener::onSeekProcessed);
    }
    if (!previousState.availableCommands.equals(newState.availableCommands)) {
      listeners.queueEvent(
          Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
          listener -> listener.onAvailableCommandsChanged(newState.availableCommands));
    }
    listeners.flushEvents();
  }

  @EnsuresNonNull("state")
  private void verifyApplicationThreadAndInitState() {
    if (Thread.currentThread() != applicationLooper.getThread()) {
      String message =
          Util.formatInvariant(
              "Player is accessed on the wrong thread.\n"
                  + "Current thread: '%s'\n"
                  + "Expected thread: '%s'\n"
                  + "See https://exoplayer.dev/issues/player-accessed-on-wrong-thread",
              Thread.currentThread().getName(), applicationLooper.getThread().getName());
      throw new IllegalStateException(message);
    }
    if (state == null) {
      // First time accessing state.
      state = getState();
    }
  }

  @RequiresNonNull("state")
  private void updateStateForPendingOperation(
      ListenableFuture<?> pendingOperation, Supplier<State> placeholderStateSupplier) {
    updateStateForPendingOperation(
        pendingOperation,
        placeholderStateSupplier,
        /* seeked= */ false,
        /* isRepeatingCurrentItem= */ false);
  }

  @RequiresNonNull("state")
  private void updateStateForPendingOperation(
      ListenableFuture<?> pendingOperation,
      Supplier<State> placeholderStateSupplier,
      boolean seeked,
      boolean isRepeatingCurrentItem) {
    if (pendingOperation.isDone() && pendingOperations.isEmpty()) {
      updateStateAndInformListeners(getState(), seeked, isRepeatingCurrentItem);
    } else {
      pendingOperations.add(pendingOperation);
      State suggestedPlaceholderState = placeholderStateSupplier.get();
      updateStateAndInformListeners(
          getPlaceholderState(suggestedPlaceholderState), seeked, isRepeatingCurrentItem);
      pendingOperation.addListener(
          () -> {
            castNonNull(state); // Already checked by method @RequiresNonNull pre-condition.
            pendingOperations.remove(pendingOperation);
            if (pendingOperations.isEmpty() && !released) {
              updateStateAndInformListeners(
                  getState(), /* seeked= */ false, /* isRepeatingCurrentItem= */ false);
            }
          },
          this::postOrRunOnApplicationHandler);
    }
  }

  private void postOrRunOnApplicationHandler(Runnable runnable) {
    if (applicationHandler.getLooper() == Looper.myLooper()) {
      runnable.run();
    } else {
      applicationHandler.post(runnable);
    }
  }

  private static boolean isPlaying(State state) {
    return state.playWhenReady
        && state.playbackState == Player.STATE_READY
        && state.playbackSuppressionReason == PLAYBACK_SUPPRESSION_REASON_NONE;
  }

  private static Tracks getCurrentTracksInternal(State state) {
    return state.playlist.isEmpty()
        ? Tracks.EMPTY
        : state.playlist.get(getCurrentMediaItemIndexInternal(state)).tracks;
  }

  private static MediaMetadata getMediaMetadataInternal(State state) {
    return state.playlist.isEmpty()
        ? MediaMetadata.EMPTY
        : state.playlist.get(getCurrentMediaItemIndexInternal(state)).combinedMediaMetadata;
  }

  private static int getCurrentMediaItemIndexInternal(State state) {
    if (state.currentMediaItemIndex != C.INDEX_UNSET) {
      return state.currentMediaItemIndex;
    }
    return 0; // TODO: Use shuffle order to get first item if playlist is not empty.
  }

  private static long getContentPositionMsInternal(State state) {
    return getPositionOrDefaultInMediaItem(state.contentPositionMsSupplier.get(), state);
  }

  private static long getContentBufferedPositionMsInternal(State state) {
    return getPositionOrDefaultInMediaItem(state.contentBufferedPositionMsSupplier.get(), state);
  }

  private static long getPositionOrDefaultInMediaItem(long positionMs, State state) {
    if (positionMs != C.TIME_UNSET) {
      return positionMs;
    }
    if (state.playlist.isEmpty()) {
      return 0;
    }
    return usToMs(state.playlist.get(getCurrentMediaItemIndexInternal(state)).defaultPositionUs);
  }

  private static int getCurrentPeriodIndexInternal(
      State state, Timeline.Window window, Timeline.Period period) {
    int currentMediaItemIndex = getCurrentMediaItemIndexInternal(state);
    if (state.timeline.isEmpty()) {
      return currentMediaItemIndex;
    }
    return getPeriodIndexFromWindowPosition(
        state.timeline, currentMediaItemIndex, getContentPositionMsInternal(state), window, period);
  }

  private static int getPeriodIndexFromWindowPosition(
      Timeline timeline,
      int windowIndex,
      long windowPositionMs,
      Timeline.Window window,
      Timeline.Period period) {
    Object periodUid =
        timeline.getPeriodPositionUs(window, period, windowIndex, msToUs(windowPositionMs)).first;
    return timeline.getIndexOfPeriod(periodUid);
  }

  private static @Player.TimelineChangeReason int getTimelineChangeReason(
      List<MediaItemData> previousPlaylist, List<MediaItemData> newPlaylist) {
    if (previousPlaylist.size() != newPlaylist.size()) {
      return Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED;
    }
    for (int i = 0; i < previousPlaylist.size(); i++) {
      Object previousUid = previousPlaylist.get(i).uid;
      Object newUid = newPlaylist.get(i).uid;
      boolean resolvedAutoGeneratedPlaceholder =
          previousUid instanceof PlaceholderUid && !(newUid instanceof PlaceholderUid);
      if (!previousUid.equals(newUid) && !resolvedAutoGeneratedPlaceholder) {
        return Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED;
      }
    }
    return Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE;
  }

  private static int getPositionDiscontinuityReason(
      State previousState,
      State newState,
      boolean seeked,
      Timeline.Window window,
      Timeline.Period period) {
    if (newState.hasPositionDiscontinuity) {
      // We were asked to report a discontinuity.
      return newState.positionDiscontinuityReason;
    }
    if (seeked) {
      return Player.DISCONTINUITY_REASON_SEEK;
    }
    if (previousState.playlist.isEmpty()) {
      // First change from an empty playlist is not reported as a discontinuity.
      return C.INDEX_UNSET;
    }
    if (newState.playlist.isEmpty()) {
      // The playlist became empty.
      return Player.DISCONTINUITY_REASON_REMOVE;
    }
    Object previousPeriodUid =
        previousState.timeline.getUidOfPeriod(
            getCurrentPeriodIndexInternal(previousState, window, period));
    Object newPeriodUid =
        newState.timeline.getUidOfPeriod(getCurrentPeriodIndexInternal(newState, window, period));
    if (previousPeriodUid instanceof PlaceholderUid && !(newPeriodUid instanceof PlaceholderUid)) {
      // An auto-generated placeholder was resolved to a real item.
      return C.INDEX_UNSET;
    }
    if (!newPeriodUid.equals(previousPeriodUid)
        || previousState.currentAdGroupIndex != newState.currentAdGroupIndex
        || previousState.currentAdIndexInAdGroup != newState.currentAdIndexInAdGroup) {
      // The current period or ad inside a period changed.
      if (newState.timeline.getIndexOfPeriod(previousPeriodUid) == C.INDEX_UNSET) {
        // The previous period no longer exists.
        return Player.DISCONTINUITY_REASON_REMOVE;
      }
      // Check if reached the previous period's or ad's duration to assume an auto-transition.
      long previousPositionMs =
          getCurrentPeriodOrAdPositionMs(previousState, previousPeriodUid, period);
      long previousDurationMs = getPeriodOrAdDurationMs(previousState, previousPeriodUid, period);
      return previousDurationMs != C.TIME_UNSET && previousPositionMs >= previousDurationMs
          ? Player.DISCONTINUITY_REASON_AUTO_TRANSITION
          : Player.DISCONTINUITY_REASON_SKIP;
    }
    // We are in the same content period or ad. Check if the position deviates more than a
    // reasonable threshold from the previous one.
    long previousPositionMs =
        getCurrentPeriodOrAdPositionMs(previousState, previousPeriodUid, period);
    long newPositionMs = getCurrentPeriodOrAdPositionMs(newState, newPeriodUid, period);
    if (Math.abs(previousPositionMs - newPositionMs) < POSITION_DISCONTINUITY_THRESHOLD_MS) {
      return C.INDEX_UNSET;
    }
    // Check if we previously reached the end of the item to assume an auto-repetition.
    long previousDurationMs = getPeriodOrAdDurationMs(previousState, previousPeriodUid, period);
    return previousDurationMs != C.TIME_UNSET && previousPositionMs >= previousDurationMs
        ? Player.DISCONTINUITY_REASON_AUTO_TRANSITION
        : Player.DISCONTINUITY_REASON_INTERNAL;
  }

  private static long getCurrentPeriodOrAdPositionMs(
      State state, Object currentPeriodUid, Timeline.Period period) {
    return state.currentAdGroupIndex != C.INDEX_UNSET
        ? state.adPositionMsSupplier.get()
        : getContentPositionMsInternal(state)
            - state.timeline.getPeriodByUid(currentPeriodUid, period).getPositionInWindowMs();
  }

  private static long getPeriodOrAdDurationMs(
      State state, Object currentPeriodUid, Timeline.Period period) {
    state.timeline.getPeriodByUid(currentPeriodUid, period);
    long periodOrAdDurationUs =
        state.currentAdGroupIndex == C.INDEX_UNSET
            ? period.durationUs
            : period.getAdDurationUs(state.currentAdGroupIndex, state.currentAdIndexInAdGroup);
    return usToMs(periodOrAdDurationUs);
  }

  private static PositionInfo getPositionInfo(
      State state,
      boolean useDiscontinuityPosition,
      Timeline.Window window,
      Timeline.Period period) {
    @Nullable Object windowUid = null;
    @Nullable Object periodUid = null;
    int mediaItemIndex = getCurrentMediaItemIndexInternal(state);
    int periodIndex = C.INDEX_UNSET;
    @Nullable MediaItem mediaItem = null;
    if (!state.timeline.isEmpty()) {
      periodIndex = getCurrentPeriodIndexInternal(state, window, period);
      periodUid = state.timeline.getPeriod(periodIndex, period, /* setIds= */ true).uid;
      windowUid = state.timeline.getWindow(mediaItemIndex, window).uid;
      mediaItem = window.mediaItem;
    }
    long contentPositionMs;
    long positionMs;
    if (useDiscontinuityPosition) {
      positionMs = state.discontinuityPositionMs;
      contentPositionMs =
          state.currentAdGroupIndex == C.INDEX_UNSET
              ? positionMs
              : getContentPositionMsInternal(state);
    } else {
      contentPositionMs = getContentPositionMsInternal(state);
      positionMs =
          state.currentAdGroupIndex != C.INDEX_UNSET
              ? state.adPositionMsSupplier.get()
              : contentPositionMs;
    }
    return new PositionInfo(
        windowUid,
        mediaItemIndex,
        mediaItem,
        periodUid,
        periodIndex,
        positionMs,
        contentPositionMs,
        state.currentAdGroupIndex,
        state.currentAdIndexInAdGroup);
  }

  private static int getMediaItemTransitionReason(
      State previousState,
      State newState,
      int positionDiscontinuityReason,
      boolean isRepeatingCurrentItem,
      Timeline.Window window) {
    Timeline previousTimeline = previousState.timeline;
    Timeline newTimeline = newState.timeline;
    if (newTimeline.isEmpty() && previousTimeline.isEmpty()) {
      return C.INDEX_UNSET;
    } else if (newTimeline.isEmpty() != previousTimeline.isEmpty()) {
      return MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED;
    }
    Object previousWindowUid =
        previousState.timeline.getWindow(getCurrentMediaItemIndexInternal(previousState), window)
            .uid;
    Object newWindowUid =
        newState.timeline.getWindow(getCurrentMediaItemIndexInternal(newState), window).uid;
    if (previousWindowUid instanceof PlaceholderUid && !(newWindowUid instanceof PlaceholderUid)) {
      // An auto-generated placeholder was resolved to a real item.
      return C.INDEX_UNSET;
    }
    if (!previousWindowUid.equals(newWindowUid)) {
      if (positionDiscontinuityReason == DISCONTINUITY_REASON_AUTO_TRANSITION) {
        return MEDIA_ITEM_TRANSITION_REASON_AUTO;
      } else if (positionDiscontinuityReason == DISCONTINUITY_REASON_SEEK) {
        return MEDIA_ITEM_TRANSITION_REASON_SEEK;
      } else {
        return MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED;
      }
    }
    // Only mark changes within the current item as a transition if we are repeating automatically
    // or via a seek to next/previous.
    if (positionDiscontinuityReason == DISCONTINUITY_REASON_AUTO_TRANSITION
        && getContentPositionMsInternal(previousState) > getContentPositionMsInternal(newState)) {
      return MEDIA_ITEM_TRANSITION_REASON_REPEAT;
    }
    if (positionDiscontinuityReason == DISCONTINUITY_REASON_SEEK && isRepeatingCurrentItem) {
      return MEDIA_ITEM_TRANSITION_REASON_SEEK;
    }
    return C.INDEX_UNSET;
  }

  private static Size getSurfaceHolderSize(SurfaceHolder surfaceHolder) {
    if (!surfaceHolder.getSurface().isValid()) {
      return Size.ZERO;
    }
    Rect surfaceFrame = surfaceHolder.getSurfaceFrame();
    return new Size(surfaceFrame.width(), surfaceFrame.height());
  }

  private static int getMediaItemIndexInNewPlaylist(
      List<MediaItemData> oldPlaylist,
      Timeline newPlaylistTimeline,
      int oldMediaItemIndex,
      Timeline.Period period) {
    if (oldPlaylist.isEmpty()) {
      return oldMediaItemIndex < newPlaylistTimeline.getWindowCount()
          ? oldMediaItemIndex
          : C.INDEX_UNSET;
    }
    Object oldFirstPeriodUid =
        oldPlaylist.get(oldMediaItemIndex).getPeriodUid(/* periodIndexInMediaItem= */ 0);
    if (newPlaylistTimeline.getIndexOfPeriod(oldFirstPeriodUid) == C.INDEX_UNSET) {
      return C.INDEX_UNSET;
    }
    return newPlaylistTimeline.getPeriodByUid(oldFirstPeriodUid, period).windowIndex;
  }

  private static State getStateWithNewPlaylist(
      State oldState, List<MediaItemData> newPlaylist, Timeline.Period period) {
    State.Builder stateBuilder = oldState.buildUpon();
    stateBuilder.setPlaylist(newPlaylist);
    Timeline newTimeline = stateBuilder.timeline;
    long oldPositionMs = oldState.contentPositionMsSupplier.get();
    int oldIndex = getCurrentMediaItemIndexInternal(oldState);
    int newIndex = getMediaItemIndexInNewPlaylist(oldState.playlist, newTimeline, oldIndex, period);
    long newPositionMs = newIndex == C.INDEX_UNSET ? C.TIME_UNSET : oldPositionMs;
    // If the current item no longer exists, try to find a matching subsequent item.
    for (int i = oldIndex + 1; newIndex == C.INDEX_UNSET && i < oldState.playlist.size(); i++) {
      // TODO: Use shuffle order to iterate.
      newIndex =
          getMediaItemIndexInNewPlaylist(
              oldState.playlist, newTimeline, /* oldMediaItemIndex= */ i, period);
    }
    // If this fails, transition to ENDED state.
    if (oldState.playbackState != Player.STATE_IDLE && newIndex == C.INDEX_UNSET) {
      stateBuilder.setPlaybackState(Player.STATE_ENDED).setIsLoading(false);
    }
    return buildStateForNewPosition(
        stateBuilder,
        oldState,
        oldPositionMs,
        newPlaylist,
        newIndex,
        newPositionMs,
        /* keepAds= */ true);
  }

  private static State getStateWithNewPlaylistAndPosition(
      State oldState, List<MediaItemData> newPlaylist, int newIndex, long newPositionMs) {
    State.Builder stateBuilder = oldState.buildUpon();
    stateBuilder.setPlaylist(newPlaylist);
    if (oldState.playbackState != Player.STATE_IDLE) {
      if (newPlaylist.isEmpty()) {
        stateBuilder.setPlaybackState(Player.STATE_ENDED).setIsLoading(false);
      } else {
        stateBuilder.setPlaybackState(Player.STATE_BUFFERING);
      }
    }
    long oldPositionMs = oldState.contentPositionMsSupplier.get();
    return buildStateForNewPosition(
        stateBuilder,
        oldState,
        oldPositionMs,
        newPlaylist,
        newIndex,
        newPositionMs,
        /* keepAds= */ false);
  }

  private static State buildStateForNewPosition(
      State.Builder stateBuilder,
      State oldState,
      long oldPositionMs,
      List<MediaItemData> newPlaylist,
      int newIndex,
      long newPositionMs,
      boolean keepAds) {
    // Resolve unset or invalid index and position.
    oldPositionMs = getPositionOrDefaultInMediaItem(oldPositionMs, oldState);
    if (!newPlaylist.isEmpty() && (newIndex == C.INDEX_UNSET || newIndex >= newPlaylist.size())) {
      newIndex = 0; // TODO: Use shuffle order to get first index.
      newPositionMs = C.TIME_UNSET;
    }
    if (!newPlaylist.isEmpty() && newPositionMs == C.TIME_UNSET) {
      newPositionMs = usToMs(newPlaylist.get(newIndex).defaultPositionUs);
    }
    boolean oldOrNewPlaylistEmpty = oldState.playlist.isEmpty() || newPlaylist.isEmpty();
    boolean mediaItemChanged =
        !oldOrNewPlaylistEmpty
            && !oldState
                .playlist
                .get(getCurrentMediaItemIndexInternal(oldState))
                .uid
                .equals(newPlaylist.get(newIndex).uid);
    if (oldOrNewPlaylistEmpty || mediaItemChanged || newPositionMs < oldPositionMs) {
      // New item or seeking back. Assume no buffer and no ad playback persists.
      stateBuilder
          .setCurrentMediaItemIndex(newIndex)
          .setCurrentAd(C.INDEX_UNSET, C.INDEX_UNSET)
          .setContentPositionMs(newPositionMs)
          .setContentBufferedPositionMs(PositionSupplier.getConstant(newPositionMs))
          .setTotalBufferedDurationMs(PositionSupplier.ZERO);
    } else if (newPositionMs == oldPositionMs) {
      // Unchanged position. Assume ad playback and buffer in current item persists.
      stateBuilder.setCurrentMediaItemIndex(newIndex);
      if (oldState.currentAdGroupIndex != C.INDEX_UNSET && keepAds) {
        stateBuilder.setTotalBufferedDurationMs(
            PositionSupplier.getConstant(
                oldState.adBufferedPositionMsSupplier.get() - oldState.adPositionMsSupplier.get()));
      } else {
        stateBuilder
            .setCurrentAd(C.INDEX_UNSET, C.INDEX_UNSET)
            .setTotalBufferedDurationMs(
                PositionSupplier.getConstant(
                    getContentBufferedPositionMsInternal(oldState) - oldPositionMs));
      }
    } else {
      // Seeking forward. Assume remaining buffer in current item persist, but no ad playback.
      long contentBufferedDurationMs =
          max(getContentBufferedPositionMsInternal(oldState), newPositionMs);
      long totalBufferedDurationMs =
          max(0, oldState.totalBufferedDurationMsSupplier.get() - (newPositionMs - oldPositionMs));
      stateBuilder
          .setCurrentMediaItemIndex(newIndex)
          .setCurrentAd(C.INDEX_UNSET, C.INDEX_UNSET)
          .setContentPositionMs(newPositionMs)
          .setContentBufferedPositionMs(PositionSupplier.getConstant(contentBufferedDurationMs))
          .setTotalBufferedDurationMs(PositionSupplier.getConstant(totalBufferedDurationMs));
    }
    return stateBuilder.build();
  }

  private static final class PlaceholderUid {}
}