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

import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Util.castNonNull;
import static java.lang.Math.min;

import android.os.Looper;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.TextureView;
import androidx.annotation.IntRange;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.BasePlayer;
import androidx.media3.common.C;
import androidx.media3.common.DeviceInfo;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks;
import androidx.media3.common.VideoSize;
import androidx.media3.common.text.CueGroup;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Clock;
import androidx.media3.common.util.ListenerSet;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.android.gms.cast.CastStatusCodes;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaQueueItem;
import com.google.android.gms.cast.MediaStatus;
import com.google.android.gms.cast.MediaTrack;
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.cast.framework.CastSession;
import com.google.android.gms.cast.framework.SessionManager;
import com.google.android.gms.cast.framework.SessionManagerListener;
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
import com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult;
import com.google.android.gms.common.api.PendingResult;
import com.google.android.gms.common.api.ResultCallback;
import com.google.common.collect.ImmutableList;
import java.util.List;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;

/**
 * {@link Player} implementation that communicates with a Cast receiver app.
 *
 * <p>The behavior of this class depends on the underlying Cast session, which is obtained from the
 * injected {@link CastContext}. To keep track of the session, {@link #isCastSessionAvailable()} can
 * be queried and {@link SessionAvailabilityListener} can be implemented and attached to the player.
 *
 * <p>If no session is available, the player state will remain unchanged and calls to methods that
 * alter it will be ignored. Querying the player state is possible even when no session is
 * available, in which case, the last observed receiver app state is reported.
 *
 * <p>Methods should be called on the application's main thread.
 */
@UnstableApi
public final class CastPlayer extends BasePlayer {

  static {
    MediaLibraryInfo.registerModule("media3.cast");
  }

  @VisibleForTesting
  /* package */ static final Commands PERMANENT_AVAILABLE_COMMANDS =
      new Commands.Builder()
          .addAll(
              COMMAND_PLAY_PAUSE,
              COMMAND_PREPARE,
              COMMAND_STOP,
              COMMAND_SEEK_TO_DEFAULT_POSITION,
              COMMAND_SEEK_TO_MEDIA_ITEM,
              COMMAND_SET_REPEAT_MODE,
              COMMAND_SET_SPEED_AND_PITCH,
              COMMAND_GET_CURRENT_MEDIA_ITEM,
              COMMAND_GET_TIMELINE,
              COMMAND_GET_MEDIA_ITEMS_METADATA,
              COMMAND_SET_MEDIA_ITEMS_METADATA,
              COMMAND_SET_MEDIA_ITEM,
              COMMAND_CHANGE_MEDIA_ITEMS,
              COMMAND_GET_TRACKS)
          .build();

  public static final float MIN_SPEED_SUPPORTED = 0.5f;
  public static final float MAX_SPEED_SUPPORTED = 2.0f;

  private static final String TAG = "CastPlayer";

  private static final long PROGRESS_REPORT_PERIOD_MS = 1000;
  private static final long[] EMPTY_TRACK_ID_ARRAY = new long[0];

  private final CastContext castContext;
  private final MediaItemConverter mediaItemConverter;
  private final long seekBackIncrementMs;
  private final long seekForwardIncrementMs;
  // TODO: Allow custom implementations of CastTimelineTracker.
  private final CastTimelineTracker timelineTracker;
  private final Timeline.Period period;

  // Result callbacks.
  private final StatusListener statusListener;
  private final SeekResultCallback seekResultCallback;

  // Listeners and notification.
  private final ListenerSet<Listener> listeners;
  @Nullable private SessionAvailabilityListener sessionAvailabilityListener;

  // Internal state.
  private final StateHolder<Boolean> playWhenReady;
  private final StateHolder<Integer> repeatMode;
  private final StateHolder<PlaybackParameters> playbackParameters;
  @Nullable private RemoteMediaClient remoteMediaClient;
  private CastTimeline currentTimeline;
  private Tracks currentTracks;
  private Commands availableCommands;
  private @Player.State int playbackState;
  private int currentWindowIndex;
  private long lastReportedPositionMs;
  private int pendingSeekCount;
  private int pendingSeekWindowIndex;
  private long pendingSeekPositionMs;
  @Nullable private PositionInfo pendingMediaItemRemovalPosition;
  private MediaMetadata mediaMetadata;

  /**
   * Creates a new cast player.
   *
   * <p>The returned player uses a {@link DefaultMediaItemConverter} and
   *
   * <p>{@code mediaItemConverter} is set to a {@link DefaultMediaItemConverter}, {@code
   * seekBackIncrementMs} is set to {@link C#DEFAULT_SEEK_BACK_INCREMENT_MS} and {@code
   * seekForwardIncrementMs} is set to {@link C#DEFAULT_SEEK_FORWARD_INCREMENT_MS}.
   *
   * @param castContext The context from which the cast session is obtained.
   */
  public CastPlayer(CastContext castContext) {
    this(castContext, new DefaultMediaItemConverter());
  }

  /**
   * Creates a new cast player.
   *
   * <p>{@code seekBackIncrementMs} is set to {@link C#DEFAULT_SEEK_BACK_INCREMENT_MS} and {@code
   * seekForwardIncrementMs} is set to {@link C#DEFAULT_SEEK_FORWARD_INCREMENT_MS}.
   *
   * @param castContext The context from which the cast session is obtained.
   * @param mediaItemConverter The {@link MediaItemConverter} to use.
   */
  public CastPlayer(CastContext castContext, MediaItemConverter mediaItemConverter) {
    this(
        castContext,
        mediaItemConverter,
        C.DEFAULT_SEEK_BACK_INCREMENT_MS,
        C.DEFAULT_SEEK_FORWARD_INCREMENT_MS);
  }

  /**
   * Creates a new cast player.
   *
   * @param castContext The context from which the cast session is obtained.
   * @param mediaItemConverter The {@link MediaItemConverter} to use.
   * @param seekBackIncrementMs The {@link #seekBack()} increment, in milliseconds.
   * @param seekForwardIncrementMs The {@link #seekForward()} increment, in milliseconds.
   * @throws IllegalArgumentException If {@code seekBackIncrementMs} or {@code
   *     seekForwardIncrementMs} is non-positive.
   */
  public CastPlayer(
      CastContext castContext,
      MediaItemConverter mediaItemConverter,
      @IntRange(from = 1) long seekBackIncrementMs,
      @IntRange(from = 1) long seekForwardIncrementMs) {
    checkArgument(seekBackIncrementMs > 0 && seekForwardIncrementMs > 0);
    this.castContext = castContext;
    this.mediaItemConverter = mediaItemConverter;
    this.seekBackIncrementMs = seekBackIncrementMs;
    this.seekForwardIncrementMs = seekForwardIncrementMs;
    timelineTracker = new CastTimelineTracker(mediaItemConverter);
    period = new Timeline.Period();
    statusListener = new StatusListener();
    seekResultCallback = new SeekResultCallback();
    listeners =
        new ListenerSet<>(
            Looper.getMainLooper(),
            Clock.DEFAULT,
            (listener, flags) -> listener.onEvents(/* player= */ this, new Events(flags)));
    playWhenReady = new StateHolder<>(false);
    repeatMode = new StateHolder<>(REPEAT_MODE_OFF);
    playbackParameters = new StateHolder<>(PlaybackParameters.DEFAULT);
    playbackState = STATE_IDLE;
    currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE;
    mediaMetadata = MediaMetadata.EMPTY;
    currentTracks = Tracks.EMPTY;
    availableCommands = new Commands.Builder().addAll(PERMANENT_AVAILABLE_COMMANDS).build();
    pendingSeekWindowIndex = C.INDEX_UNSET;
    pendingSeekPositionMs = C.TIME_UNSET;

    SessionManager sessionManager = castContext.getSessionManager();
    sessionManager.addSessionManagerListener(statusListener, CastSession.class);
    CastSession session = sessionManager.getCurrentCastSession();
    setRemoteMediaClient(session != null ? session.getRemoteMediaClient() : null);
    updateInternalStateAndNotifyIfChanged();
  }

  /**
   * Returns the item that corresponds to the period with the given id, or null if no media queue or
   * period with id {@code periodId} exist.
   *
   * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item
   *     to get.
   * @return The item that corresponds to the period with the given id, or null if no media queue or
   *     period with id {@code periodId} exist.
   */
  @Nullable
  public MediaQueueItem getItem(int periodId) {
    MediaStatus mediaStatus = getMediaStatus();
    return mediaStatus != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET
        ? mediaStatus.getItemById(periodId)
        : null;
  }

  // CastSession methods.

  /** Returns whether a cast session is available. */
  public boolean isCastSessionAvailable() {
    return remoteMediaClient != null;
  }

  /**
   * Sets a listener for updates on the cast session availability.
   *
   * @param listener The {@link SessionAvailabilityListener}, or null to clear the listener.
   */
  public void setSessionAvailabilityListener(@Nullable SessionAvailabilityListener listener) {
    sessionAvailabilityListener = listener;
  }

  // Player implementation.

  @Override
  public Looper getApplicationLooper() {
    return Looper.getMainLooper();
  }

  @Override
  public void addListener(Listener listener) {
    listeners.add(listener);
  }

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

  @Override
  public void setMediaItems(List<MediaItem> mediaItems, boolean resetPosition) {
    int mediaItemIndex = resetPosition ? 0 : getCurrentMediaItemIndex();
    long startPositionMs = resetPosition ? C.TIME_UNSET : getContentPosition();
    setMediaItems(mediaItems, mediaItemIndex, startPositionMs);
  }

  @Override
  public void setMediaItems(List<MediaItem> mediaItems, int startIndex, long startPositionMs) {
    setMediaItemsInternal(mediaItems, startIndex, startPositionMs, repeatMode.value);
  }

  @Override
  public void addMediaItems(int index, List<MediaItem> mediaItems) {
    Assertions.checkArgument(index >= 0);
    int uid = MediaQueueItem.INVALID_ITEM_ID;
    if (index < currentTimeline.getWindowCount()) {
      uid = (int) currentTimeline.getWindow(/* windowIndex= */ index, window).uid;
    }
    addMediaItemsInternal(mediaItems, uid);
  }

  @Override
  public void moveMediaItems(int fromIndex, int toIndex, int newIndex) {
    Assertions.checkArgument(
        fromIndex >= 0
            && fromIndex <= toIndex
            && toIndex <= currentTimeline.getWindowCount()
            && newIndex >= 0
            && newIndex < currentTimeline.getWindowCount());
    newIndex = min(newIndex, currentTimeline.getWindowCount() - (toIndex - fromIndex));
    if (fromIndex == toIndex || fromIndex == newIndex) {
      // Do nothing.
      return;
    }
    int[] uids = new int[toIndex - fromIndex];
    for (int i = 0; i < uids.length; i++) {
      uids[i] = (int) currentTimeline.getWindow(/* windowIndex= */ i + fromIndex, window).uid;
    }
    moveMediaItemsInternal(uids, fromIndex, newIndex);
  }

  @Override
  public void removeMediaItems(int fromIndex, int toIndex) {
    Assertions.checkArgument(fromIndex >= 0 && toIndex >= fromIndex);
    toIndex = min(toIndex, currentTimeline.getWindowCount());
    if (fromIndex == toIndex) {
      // Do nothing.
      return;
    }
    int[] uids = new int[toIndex - fromIndex];
    for (int i = 0; i < uids.length; i++) {
      uids[i] = (int) currentTimeline.getWindow(/* windowIndex= */ i + fromIndex, window).uid;
    }
    removeMediaItemsInternal(uids);
  }

  @Override
  public Commands getAvailableCommands() {
    return availableCommands;
  }

  @Override
  public void prepare() {
    // Do nothing.
  }

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

  @Override
  public @PlaybackSuppressionReason int getPlaybackSuppressionReason() {
    return Player.PLAYBACK_SUPPRESSION_REASON_NONE;
  }

  @Override
  @Nullable
  public PlaybackException getPlayerError() {
    return null;
  }

  @Override
  public void setPlayWhenReady(boolean playWhenReady) {
    if (remoteMediaClient == null) {
      return;
    }
    // We update the local state and send the message to the receiver app, which will cause the
    // operation to be perceived as synchronous by the user. When the operation reports a result,
    // the local state will be updated to reflect the state reported by the Cast SDK.
    setPlayerStateAndNotifyIfChanged(
        playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState);
    listeners.flushEvents();
    PendingResult<MediaChannelResult> pendingResult =
        playWhenReady ? remoteMediaClient.play() : remoteMediaClient.pause();
    this.playWhenReady.pendingResultCallback =
        new ResultCallback<MediaChannelResult>() {
          @Override
          public void onResult(MediaChannelResult mediaChannelResult) {
            if (remoteMediaClient != null) {
              updatePlayerStateAndNotifyIfChanged(this);
              listeners.flushEvents();
            }
          }
        };
    pendingResult.setResultCallback(this.playWhenReady.pendingResultCallback);
  }

  @Override
  public boolean getPlayWhenReady() {
    return playWhenReady.value;
  }

  // We still call Listener#onSeekProcessed() for backwards compatibility with listeners that
  // don't implement onPositionDiscontinuity().
  @SuppressWarnings("deprecation")
  @Override
  public void seekTo(int mediaItemIndex, long positionMs) {
    MediaStatus mediaStatus = getMediaStatus();
    // We assume the default position is 0. There is no support for seeking to the default position
    // in RemoteMediaClient.
    positionMs = positionMs != C.TIME_UNSET ? positionMs : 0;
    if (mediaStatus != null) {
      if (getCurrentMediaItemIndex() != mediaItemIndex) {
        remoteMediaClient
            .queueJumpToItem(
                (int) currentTimeline.getPeriod(mediaItemIndex, period).uid, positionMs, null)
            .setResultCallback(seekResultCallback);
      } else {
        remoteMediaClient.seek(positionMs).setResultCallback(seekResultCallback);
      }
      PositionInfo oldPosition = getCurrentPositionInfo();
      pendingSeekCount++;
      pendingSeekWindowIndex = mediaItemIndex;
      pendingSeekPositionMs = positionMs;
      PositionInfo newPosition = getCurrentPositionInfo();
      listeners.queueEvent(
          Player.EVENT_POSITION_DISCONTINUITY,
          listener -> {
            listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK);
            listener.onPositionDiscontinuity(oldPosition, newPosition, DISCONTINUITY_REASON_SEEK);
          });
      if (oldPosition.mediaItemIndex != newPosition.mediaItemIndex) {
        // TODO(internal b/182261884): queue `onMediaItemTransition` event when the media item is
        // repeated.
        MediaItem mediaItem = getCurrentTimeline().getWindow(mediaItemIndex, window).mediaItem;
        listeners.queueEvent(
            Player.EVENT_MEDIA_ITEM_TRANSITION,
            listener ->
                listener.onMediaItemTransition(mediaItem, MEDIA_ITEM_TRANSITION_REASON_SEEK));
        MediaMetadata oldMediaMetadata = mediaMetadata;
        mediaMetadata = getMediaMetadataInternal();
        if (!oldMediaMetadata.equals(mediaMetadata)) {
          listeners.queueEvent(
              Player.EVENT_MEDIA_METADATA_CHANGED,
              listener -> listener.onMediaMetadataChanged(mediaMetadata));
        }
      }
      updateAvailableCommandsAndNotifyIfChanged();
    } else if (pendingSeekCount == 0) {
      listeners.queueEvent(/* eventFlag= */ C.INDEX_UNSET, Listener::onSeekProcessed);
    }
    listeners.flushEvents();
  }

  @Override
  public long getSeekBackIncrement() {
    return seekBackIncrementMs;
  }

  @Override
  public long getSeekForwardIncrement() {
    return seekForwardIncrementMs;
  }

  @Override
  public long getMaxSeekToPreviousPosition() {
    return C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS;
  }

  @Override
  public PlaybackParameters getPlaybackParameters() {
    return playbackParameters.value;
  }

  @Override
  public void stop() {
    stop(/* reset= */ false);
  }

  /**
   * @deprecated Use {@link #stop()} and {@link #clearMediaItems()} (if {@code reset} is true) or
   *     just {@link #stop()} (if {@code reset} is false). Any player error will be cleared when
   *     {@link #prepare() re-preparing} the player.
   */
  @Deprecated
  @Override
  public void stop(boolean reset) {
    playbackState = STATE_IDLE;
    if (remoteMediaClient != null) {
      // TODO(b/69792021): Support or emulate stop without position reset.
      remoteMediaClient.stop();
    }
  }

  @Override
  public void release() {
    SessionManager sessionManager = castContext.getSessionManager();
    sessionManager.removeSessionManagerListener(statusListener, CastSession.class);
    sessionManager.endCurrentSession(false);
  }

  @Override
  public void setPlaybackParameters(PlaybackParameters playbackParameters) {
    if (remoteMediaClient == null) {
      return;
    }
    PlaybackParameters actualPlaybackParameters =
        new PlaybackParameters(
            Util.constrainValue(
                playbackParameters.speed, MIN_SPEED_SUPPORTED, MAX_SPEED_SUPPORTED));
    setPlaybackParametersAndNotifyIfChanged(actualPlaybackParameters);
    listeners.flushEvents();
    PendingResult<MediaChannelResult> pendingResult =
        remoteMediaClient.setPlaybackRate(actualPlaybackParameters.speed, /* customData= */ null);
    this.playbackParameters.pendingResultCallback =
        new ResultCallback<MediaChannelResult>() {
          @Override
          public void onResult(MediaChannelResult mediaChannelResult) {
            if (remoteMediaClient != null) {
              updatePlaybackRateAndNotifyIfChanged(this);
              listeners.flushEvents();
            }
          }
        };
    pendingResult.setResultCallback(this.playbackParameters.pendingResultCallback);
  }

  @Override
  public void setRepeatMode(@RepeatMode int repeatMode) {
    if (remoteMediaClient == null) {
      return;
    }
    // We update the local state and send the message to the receiver app, which will cause the
    // operation to be perceived as synchronous by the user. When the operation reports a result,
    // the local state will be updated to reflect the state reported by the Cast SDK.
    setRepeatModeAndNotifyIfChanged(repeatMode);
    listeners.flushEvents();
    PendingResult<MediaChannelResult> pendingResult =
        remoteMediaClient.queueSetRepeatMode(getCastRepeatMode(repeatMode), /* customData= */ null);
    this.repeatMode.pendingResultCallback =
        new ResultCallback<MediaChannelResult>() {
          @Override
          public void onResult(MediaChannelResult mediaChannelResult) {
            if (remoteMediaClient != null) {
              updateRepeatModeAndNotifyIfChanged(this);
              listeners.flushEvents();
            }
          }
        };
    pendingResult.setResultCallback(this.repeatMode.pendingResultCallback);
  }

  @Override
  public @RepeatMode int getRepeatMode() {
    return repeatMode.value;
  }

  @Override
  public void setShuffleModeEnabled(boolean shuffleModeEnabled) {
    // TODO: Support shuffle mode.
  }

  @Override
  public boolean getShuffleModeEnabled() {
    // TODO: Support shuffle mode.
    return false;
  }

  @Override
  public Tracks getCurrentTracks() {
    return currentTracks;
  }

  @Override
  public TrackSelectionParameters getTrackSelectionParameters() {
    return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT;
  }

  @Override
  public void setTrackSelectionParameters(TrackSelectionParameters parameters) {}

  @Override
  public MediaMetadata getMediaMetadata() {
    return mediaMetadata;
  }

  public MediaMetadata getMediaMetadataInternal() {
    MediaItem currentMediaItem = getCurrentMediaItem();
    return currentMediaItem != null ? currentMediaItem.mediaMetadata : MediaMetadata.EMPTY;
  }

  @Override
  public MediaMetadata getPlaylistMetadata() {
    // CastPlayer does not currently support metadata.
    return MediaMetadata.EMPTY;
  }

  /** This method is not supported and does nothing. */
  @Override
  public void setPlaylistMetadata(MediaMetadata mediaMetadata) {
    // CastPlayer does not currently support metadata.
  }

  @Override
  public Timeline getCurrentTimeline() {
    return currentTimeline;
  }

  @Override
  public int getCurrentPeriodIndex() {
    return getCurrentMediaItemIndex();
  }

  @Override
  public int getCurrentMediaItemIndex() {
    return pendingSeekWindowIndex != C.INDEX_UNSET ? pendingSeekWindowIndex : currentWindowIndex;
  }

  // TODO: Fill the cast timeline information with ProgressListener's duration updates.
  // See [Internal: b/65152553].
  @Override
  public long getDuration() {
    return getContentDuration();
  }

  @Override
  public long getCurrentPosition() {
    return pendingSeekPositionMs != C.TIME_UNSET
        ? pendingSeekPositionMs
        : remoteMediaClient != null
            ? remoteMediaClient.getApproximateStreamPosition()
            : lastReportedPositionMs;
  }

  @Override
  public long getBufferedPosition() {
    return getCurrentPosition();
  }

  @Override
  public long getTotalBufferedDuration() {
    long bufferedPosition = getBufferedPosition();
    long currentPosition = getCurrentPosition();
    return bufferedPosition == C.TIME_UNSET || currentPosition == C.TIME_UNSET
        ? 0
        : bufferedPosition - currentPosition;
  }

  @Override
  public boolean isPlayingAd() {
    return false;
  }

  @Override
  public int getCurrentAdGroupIndex() {
    return C.INDEX_UNSET;
  }

  @Override
  public int getCurrentAdIndexInAdGroup() {
    return C.INDEX_UNSET;
  }

  @Override
  public boolean isLoading() {
    return false;
  }

  @Override
  public long getContentPosition() {
    return getCurrentPosition();
  }

  @Override
  public long getContentBufferedPosition() {
    return getBufferedPosition();
  }

  /** This method is not supported and returns {@link AudioAttributes#DEFAULT}. */
  @Override
  public AudioAttributes getAudioAttributes() {
    return AudioAttributes.DEFAULT;
  }

  /** This method is not supported and does nothing. */
  @Override
  public void setVolume(float volume) {}

  /** This method is not supported and returns 1. */
  @Override
  public float getVolume() {
    return 1;
  }

  /** This method is not supported and does nothing. */
  @Override
  public void clearVideoSurface() {}

  /** This method is not supported and does nothing. */
  @Override
  public void clearVideoSurface(@Nullable Surface surface) {}

  /** This method is not supported and does nothing. */
  @Override
  public void setVideoSurface(@Nullable Surface surface) {}

  /** This method is not supported and does nothing. */
  @Override
  public void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {}

  /** This method is not supported and does nothing. */
  @Override
  public void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {}

  /** This method is not supported and does nothing. */
  @Override
  public void setVideoSurfaceView(@Nullable SurfaceView surfaceView) {}

  /** This method is not supported and does nothing. */
  @Override
  public void clearVideoSurfaceView(@Nullable SurfaceView surfaceView) {}

  /** This method is not supported and does nothing. */
  @Override
  public void setVideoTextureView(@Nullable TextureView textureView) {}
  /** This method is not supported and does nothing. */
  @Override
  public void clearVideoTextureView(@Nullable TextureView textureView) {}

  /** This method is not supported and returns {@link VideoSize#UNKNOWN}. */
  @Override
  public VideoSize getVideoSize() {
    return VideoSize.UNKNOWN;
  }

  /** This method is not supported and returns an empty {@link CueGroup}. */
  @Override
  public CueGroup getCurrentCues() {
    return CueGroup.EMPTY;
  }

  /** This method is not supported and always returns {@link DeviceInfo#UNKNOWN}. */
  @Override
  public DeviceInfo getDeviceInfo() {
    return DeviceInfo.UNKNOWN;
  }

  /** This method is not supported and always returns {@code 0}. */
  @Override
  public int getDeviceVolume() {
    return 0;
  }

  /** This method is not supported and always returns {@code false}. */
  @Override
  public boolean isDeviceMuted() {
    return false;
  }

  /** This method is not supported and does nothing. */
  @Override
  public void setDeviceVolume(int volume) {}

  /** This method is not supported and does nothing. */
  @Override
  public void increaseDeviceVolume() {}

  /** This method is not supported and does nothing. */
  @Override
  public void decreaseDeviceVolume() {}

  /** This method is not supported and does nothing. */
  @Override
  public void setDeviceMuted(boolean muted) {}

  // Internal methods.

  // Call deprecated callbacks.
  @SuppressWarnings("deprecation")
  private void updateInternalStateAndNotifyIfChanged() {
    if (remoteMediaClient == null) {
      // There is no session. We leave the state of the player as it is now.
      return;
    }
    int oldWindowIndex = this.currentWindowIndex;
    MediaMetadata oldMediaMetadata = mediaMetadata;
    @Nullable
    Object oldPeriodUid =
        !getCurrentTimeline().isEmpty()
            ? getCurrentTimeline().getPeriod(oldWindowIndex, period, /* setIds= */ true).uid
            : null;
    updatePlayerStateAndNotifyIfChanged(/* resultCallback= */ null);
    updateRepeatModeAndNotifyIfChanged(/* resultCallback= */ null);
    updatePlaybackRateAndNotifyIfChanged(/* resultCallback= */ null);
    boolean playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged();
    Timeline currentTimeline = getCurrentTimeline();
    currentWindowIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline);
    mediaMetadata = getMediaMetadataInternal();
    @Nullable
    Object currentPeriodUid =
        !currentTimeline.isEmpty()
            ? currentTimeline.getPeriod(currentWindowIndex, period, /* setIds= */ true).uid
            : null;
    if (!playingPeriodChangedByTimelineChange
        && !Util.areEqual(oldPeriodUid, currentPeriodUid)
        && pendingSeekCount == 0) {
      // Report discontinuity and media item auto transition.
      currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */ true);
      currentTimeline.getWindow(oldWindowIndex, window);
      long windowDurationMs = window.getDurationMs();
      PositionInfo oldPosition =
          new PositionInfo(
              window.uid,
              period.windowIndex,
              window.mediaItem,
              period.uid,
              period.windowIndex,
              /* positionMs= */ windowDurationMs,
              /* contentPositionMs= */ windowDurationMs,
              /* adGroupIndex= */ C.INDEX_UNSET,
              /* adIndexInAdGroup= */ C.INDEX_UNSET);
      currentTimeline.getPeriod(currentWindowIndex, period, /* setIds= */ true);
      currentTimeline.getWindow(currentWindowIndex, window);
      PositionInfo newPosition =
          new PositionInfo(
              window.uid,
              period.windowIndex,
              window.mediaItem,
              period.uid,
              period.windowIndex,
              /* positionMs= */ window.getDefaultPositionMs(),
              /* contentPositionMs= */ window.getDefaultPositionMs(),
              /* adGroupIndex= */ C.INDEX_UNSET,
              /* adIndexInAdGroup= */ C.INDEX_UNSET);
      listeners.queueEvent(
          Player.EVENT_POSITION_DISCONTINUITY,
          listener -> {
            listener.onPositionDiscontinuity(DISCONTINUITY_REASON_AUTO_TRANSITION);
            listener.onPositionDiscontinuity(
                oldPosition, newPosition, DISCONTINUITY_REASON_AUTO_TRANSITION);
          });
      listeners.queueEvent(
          Player.EVENT_MEDIA_ITEM_TRANSITION,
          listener ->
              listener.onMediaItemTransition(
                  getCurrentMediaItem(), MEDIA_ITEM_TRANSITION_REASON_AUTO));
    }
    if (updateTracksAndSelectionsAndNotifyIfChanged()) {
      listeners.queueEvent(
          Player.EVENT_TRACKS_CHANGED, listener -> listener.onTracksChanged(currentTracks));
    }
    if (!oldMediaMetadata.equals(mediaMetadata)) {
      listeners.queueEvent(
          Player.EVENT_MEDIA_METADATA_CHANGED,
          listener -> listener.onMediaMetadataChanged(mediaMetadata));
    }
    updateAvailableCommandsAndNotifyIfChanged();
    listeners.flushEvents();
  }

  /**
   * Updates {@link #playWhenReady} and {@link #playbackState} to match the Cast {@code
   * remoteMediaClient} state, and notifies listeners of any state changes.
   *
   * <p>This method will only update values whose {@link StateHolder#pendingResultCallback} matches
   * the given {@code resultCallback}.
   */
  @RequiresNonNull("remoteMediaClient")
  private void updatePlayerStateAndNotifyIfChanged(@Nullable ResultCallback<?> resultCallback) {
    boolean newPlayWhenReadyValue = playWhenReady.value;
    if (playWhenReady.acceptsUpdate(resultCallback)) {
      newPlayWhenReadyValue = !remoteMediaClient.isPaused();
      playWhenReady.clearPendingResultCallback();
    }
    @PlayWhenReadyChangeReason
    int playWhenReadyChangeReason =
        newPlayWhenReadyValue != playWhenReady.value
            ? PLAY_WHEN_READY_CHANGE_REASON_REMOTE
            : PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST;
    // We do not mask the playback state, so try setting it regardless of the playWhenReady masking.
    setPlayerStateAndNotifyIfChanged(
        newPlayWhenReadyValue, playWhenReadyChangeReason, fetchPlaybackState(remoteMediaClient));
  }

  @RequiresNonNull("remoteMediaClient")
  private void updatePlaybackRateAndNotifyIfChanged(@Nullable ResultCallback<?> resultCallback) {
    if (playbackParameters.acceptsUpdate(resultCallback)) {
      @Nullable MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
      float speed =
          mediaStatus != null
              ? (float) mediaStatus.getPlaybackRate()
              : PlaybackParameters.DEFAULT.speed;
      if (speed > 0.0f) {
        // Set the speed if not paused.
        setPlaybackParametersAndNotifyIfChanged(new PlaybackParameters(speed));
      }
      playbackParameters.clearPendingResultCallback();
    }
  }

  @RequiresNonNull("remoteMediaClient")
  private void updateRepeatModeAndNotifyIfChanged(@Nullable ResultCallback<?> resultCallback) {
    if (repeatMode.acceptsUpdate(resultCallback)) {
      setRepeatModeAndNotifyIfChanged(fetchRepeatMode(remoteMediaClient));
      repeatMode.clearPendingResultCallback();
    }
  }

  /**
   * Updates the timeline and notifies {@link Player.Listener event listeners} if required.
   *
   * @return Whether the timeline change has caused a change of the period currently being played.
   */
  @SuppressWarnings("deprecation") // Calling deprecated listener method.
  private boolean updateTimelineAndNotifyIfChanged() {
    Timeline oldTimeline = currentTimeline;
    int oldWindowIndex = currentWindowIndex;
    boolean playingPeriodChanged = false;
    if (updateTimeline()) {
      // TODO: Differentiate TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED and
      //     TIMELINE_CHANGE_REASON_SOURCE_UPDATE [see internal: b/65152553].
      Timeline timeline = currentTimeline;
      // Call onTimelineChanged.
      listeners.queueEvent(
          Player.EVENT_TIMELINE_CHANGED,
          listener ->
              listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE));

      // Call onPositionDiscontinuity if required.
      Timeline currentTimeline = getCurrentTimeline();
      boolean playingPeriodRemoved = false;
      if (!oldTimeline.isEmpty()) {
        Object oldPeriodUid =
            castNonNull(oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */ true).uid);
        playingPeriodRemoved = currentTimeline.getIndexOfPeriod(oldPeriodUid) == C.INDEX_UNSET;
      }
      if (playingPeriodRemoved) {
        PositionInfo oldPosition;
        if (pendingMediaItemRemovalPosition != null) {
          oldPosition = pendingMediaItemRemovalPosition;
          pendingMediaItemRemovalPosition = null;
        } else {
          // If the media item has been removed by another client, we don't know the removal
          // position. We use the current position as a fallback.
          oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */ true);
          oldTimeline.getWindow(period.windowIndex, window);
          oldPosition =
              new PositionInfo(
                  window.uid,
                  period.windowIndex,
                  window.mediaItem,
                  period.uid,
                  period.windowIndex,
                  getCurrentPosition(),
                  getContentPosition(),
                  /* adGroupIndex= */ C.INDEX_UNSET,
                  /* adIndexInAdGroup= */ C.INDEX_UNSET);
        }
        PositionInfo newPosition = getCurrentPositionInfo();
        listeners.queueEvent(
            Player.EVENT_POSITION_DISCONTINUITY,
            listener -> {
              listener.onPositionDiscontinuity(DISCONTINUITY_REASON_REMOVE);
              listener.onPositionDiscontinuity(
                  oldPosition, newPosition, DISCONTINUITY_REASON_REMOVE);
            });
      }

      // Call onMediaItemTransition if required.
      playingPeriodChanged =
          currentTimeline.isEmpty() != oldTimeline.isEmpty() || playingPeriodRemoved;
      if (playingPeriodChanged) {
        listeners.queueEvent(
            Player.EVENT_MEDIA_ITEM_TRANSITION,
            listener ->
                listener.onMediaItemTransition(
                    getCurrentMediaItem(), MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED));
      }
      updateAvailableCommandsAndNotifyIfChanged();
    }
    return playingPeriodChanged;
  }

  /**
   * Updates the current timeline. The current window index may change as a result.
   *
   * @return Whether the current timeline has changed.
   */
  private boolean updateTimeline() {
    CastTimeline oldTimeline = currentTimeline;
    MediaStatus status = getMediaStatus();
    currentTimeline =
        status != null
            ? timelineTracker.getCastTimeline(remoteMediaClient)
            : CastTimeline.EMPTY_CAST_TIMELINE;
    boolean timelineChanged = !oldTimeline.equals(currentTimeline);
    if (timelineChanged) {
      currentWindowIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline);
    }
    return timelineChanged;
  }

  /** Updates the internal tracks and selection and returns whether they have changed. */
  private boolean updateTracksAndSelectionsAndNotifyIfChanged() {
    if (remoteMediaClient == null) {
      // There is no session. We leave the state of the player as it is now.
      return false;
    }

    @Nullable MediaStatus mediaStatus = getMediaStatus();
    @Nullable MediaInfo mediaInfo = mediaStatus != null ? mediaStatus.getMediaInfo() : null;
    @Nullable
    List<MediaTrack> castMediaTracks = mediaInfo != null ? mediaInfo.getMediaTracks() : null;
    if (castMediaTracks == null || castMediaTracks.isEmpty()) {
      boolean hasChanged = !Tracks.EMPTY.equals(currentTracks);
      currentTracks = Tracks.EMPTY;
      return hasChanged;
    }
    @Nullable long[] activeTrackIds = mediaStatus.getActiveTrackIds();
    if (activeTrackIds == null) {
      activeTrackIds = EMPTY_TRACK_ID_ARRAY;
    }

    Tracks.Group[] trackGroups = new Tracks.Group[castMediaTracks.size()];
    for (int i = 0; i < castMediaTracks.size(); i++) {
      MediaTrack mediaTrack = castMediaTracks.get(i);
      TrackGroup trackGroup =
          new TrackGroup(/* id= */ Integer.toString(i), CastUtils.mediaTrackToFormat(mediaTrack));
      @C.FormatSupport int[] trackSupport = new int[] {C.FORMAT_HANDLED};
      boolean[] trackSelected = new boolean[] {isTrackActive(mediaTrack.getId(), activeTrackIds)};
      trackGroups[i] =
          new Tracks.Group(trackGroup, /* adaptiveSupported= */ false, trackSupport, trackSelected);
    }
    Tracks newTracks = new Tracks(ImmutableList.copyOf(trackGroups));
    if (!newTracks.equals(currentTracks)) {
      currentTracks = newTracks;
      return true;
    }
    return false;
  }

  private void updateAvailableCommandsAndNotifyIfChanged() {
    Commands previousAvailableCommands = availableCommands;
    availableCommands = Util.getAvailableCommands(/* player= */ this, PERMANENT_AVAILABLE_COMMANDS);
    if (!availableCommands.equals(previousAvailableCommands)) {
      listeners.queueEvent(
          Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
          listener -> listener.onAvailableCommandsChanged(availableCommands));
    }
  }

  private void setMediaItemsInternal(
      List<MediaItem> mediaItems,
      int startIndex,
      long startPositionMs,
      @RepeatMode int repeatMode) {
    if (remoteMediaClient == null || mediaItems.isEmpty()) {
      return;
    }
    startPositionMs = startPositionMs == C.TIME_UNSET ? 0 : startPositionMs;
    if (startIndex == C.INDEX_UNSET) {
      startIndex = getCurrentMediaItemIndex();
      startPositionMs = getCurrentPosition();
    }
    Timeline currentTimeline = getCurrentTimeline();
    if (!currentTimeline.isEmpty()) {
      pendingMediaItemRemovalPosition = getCurrentPositionInfo();
    }
    MediaQueueItem[] mediaQueueItems = toMediaQueueItems(mediaItems);
    timelineTracker.onMediaItemsSet(mediaItems, mediaQueueItems);
    remoteMediaClient.queueLoad(
        mediaQueueItems,
        min(startIndex, mediaItems.size() - 1),
        getCastRepeatMode(repeatMode),
        startPositionMs,
        /* customData= */ null);
  }

  private void addMediaItemsInternal(List<MediaItem> mediaItems, int uid) {
    if (remoteMediaClient == null || getMediaStatus() == null) {
      return;
    }
    MediaQueueItem[] itemsToInsert = toMediaQueueItems(mediaItems);
    timelineTracker.onMediaItemsAdded(mediaItems, itemsToInsert);
    remoteMediaClient.queueInsertItems(itemsToInsert, uid, /* customData= */ null);
  }

  private void moveMediaItemsInternal(int[] uids, int fromIndex, int newIndex) {
    if (remoteMediaClient == null || getMediaStatus() == null) {
      return;
    }
    int insertBeforeIndex = fromIndex < newIndex ? newIndex + uids.length : newIndex;
    int insertBeforeItemId = MediaQueueItem.INVALID_ITEM_ID;
    if (insertBeforeIndex < currentTimeline.getWindowCount()) {
      insertBeforeItemId = (int) currentTimeline.getWindow(insertBeforeIndex, window).uid;
    }
    remoteMediaClient.queueReorderItems(uids, insertBeforeItemId, /* customData= */ null);
  }

  @Nullable
  private PendingResult<MediaChannelResult> removeMediaItemsInternal(int[] uids) {
    if (remoteMediaClient == null || getMediaStatus() == null) {
      return null;
    }
    Timeline timeline = getCurrentTimeline();
    if (!timeline.isEmpty()) {
      Object periodUid =
          castNonNull(timeline.getPeriod(getCurrentPeriodIndex(), period, /* setIds= */ true).uid);
      for (int uid : uids) {
        if (periodUid.equals(uid)) {
          pendingMediaItemRemovalPosition = getCurrentPositionInfo();
          break;
        }
      }
    }
    return remoteMediaClient.queueRemoveItems(uids, /* customData= */ null);
  }

  private PositionInfo getCurrentPositionInfo() {
    Timeline currentTimeline = getCurrentTimeline();
    @Nullable Object newPeriodUid = null;
    @Nullable Object newWindowUid = null;
    @Nullable MediaItem newMediaItem = null;
    if (!currentTimeline.isEmpty()) {
      newPeriodUid =
          currentTimeline.getPeriod(getCurrentPeriodIndex(), period, /* setIds= */ true).uid;
      newWindowUid = currentTimeline.getWindow(period.windowIndex, window).uid;
      newMediaItem = window.mediaItem;
    }
    return new PositionInfo(
        newWindowUid,
        getCurrentMediaItemIndex(),
        newMediaItem,
        newPeriodUid,
        getCurrentPeriodIndex(),
        getCurrentPosition(),
        getContentPosition(),
        /* adGroupIndex= */ C.INDEX_UNSET,
        /* adIndexInAdGroup= */ C.INDEX_UNSET);
  }

  private void setRepeatModeAndNotifyIfChanged(@Player.RepeatMode int repeatMode) {
    if (this.repeatMode.value != repeatMode) {
      this.repeatMode.value = repeatMode;
      listeners.queueEvent(
          Player.EVENT_REPEAT_MODE_CHANGED, listener -> listener.onRepeatModeChanged(repeatMode));
      updateAvailableCommandsAndNotifyIfChanged();
    }
  }

  private void setPlaybackParametersAndNotifyIfChanged(PlaybackParameters playbackParameters) {
    if (this.playbackParameters.value.equals(playbackParameters)) {
      return;
    }
    this.playbackParameters.value = playbackParameters;
    listeners.queueEvent(
        Player.EVENT_PLAYBACK_PARAMETERS_CHANGED,
        listener -> listener.onPlaybackParametersChanged(playbackParameters));
    updateAvailableCommandsAndNotifyIfChanged();
  }

  @SuppressWarnings("deprecation")
  private void setPlayerStateAndNotifyIfChanged(
      boolean playWhenReady,
      @Player.PlayWhenReadyChangeReason int playWhenReadyChangeReason,
      @Player.State int playbackState) {
    boolean wasPlaying = this.playbackState == Player.STATE_READY && this.playWhenReady.value;
    boolean playWhenReadyChanged = this.playWhenReady.value != playWhenReady;
    boolean playbackStateChanged = this.playbackState != playbackState;
    if (playWhenReadyChanged || playbackStateChanged) {
      this.playbackState = playbackState;
      this.playWhenReady.value = playWhenReady;
      listeners.queueEvent(
          /* eventFlag= */ C.INDEX_UNSET,
          listener -> listener.onPlayerStateChanged(playWhenReady, playbackState));
      if (playbackStateChanged) {
        listeners.queueEvent(
            Player.EVENT_PLAYBACK_STATE_CHANGED,
            listener -> listener.onPlaybackStateChanged(playbackState));
      }
      if (playWhenReadyChanged) {
        listeners.queueEvent(
            Player.EVENT_PLAY_WHEN_READY_CHANGED,
            listener -> listener.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason));
      }
      boolean isPlaying = playbackState == Player.STATE_READY && playWhenReady;
      if (wasPlaying != isPlaying) {
        listeners.queueEvent(
            Player.EVENT_IS_PLAYING_CHANGED, listener -> listener.onIsPlayingChanged(isPlaying));
      }
    }
  }

  private void setRemoteMediaClient(@Nullable RemoteMediaClient remoteMediaClient) {
    if (this.remoteMediaClient == remoteMediaClient) {
      // Do nothing.
      return;
    }
    if (this.remoteMediaClient != null) {
      this.remoteMediaClient.unregisterCallback(statusListener);
      this.remoteMediaClient.removeProgressListener(statusListener);
    }
    this.remoteMediaClient = remoteMediaClient;
    if (remoteMediaClient != null) {
      if (sessionAvailabilityListener != null) {
        sessionAvailabilityListener.onCastSessionAvailable();
      }
      remoteMediaClient.registerCallback(statusListener);
      remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS);
      updateInternalStateAndNotifyIfChanged();
    } else {
      updateTimelineAndNotifyIfChanged();
      if (sessionAvailabilityListener != null) {
        sessionAvailabilityListener.onCastSessionUnavailable();
      }
    }
  }

  @Nullable
  private MediaStatus getMediaStatus() {
    return remoteMediaClient != null ? remoteMediaClient.getMediaStatus() : null;
  }

  /**
   * Retrieves the playback state from {@code remoteMediaClient} and maps it into a {@link Player}
   * state
   */
  private static int fetchPlaybackState(RemoteMediaClient remoteMediaClient) {
    int receiverAppStatus = remoteMediaClient.getPlayerState();
    switch (receiverAppStatus) {
      case MediaStatus.PLAYER_STATE_BUFFERING:
        return STATE_BUFFERING;
      case MediaStatus.PLAYER_STATE_PLAYING:
      case MediaStatus.PLAYER_STATE_PAUSED:
        return STATE_READY;
      case MediaStatus.PLAYER_STATE_IDLE:
      case MediaStatus.PLAYER_STATE_UNKNOWN:
      default:
        return STATE_IDLE;
    }
  }

  /**
   * Retrieves the repeat mode from {@code remoteMediaClient} and maps it into a {@link
   * Player.RepeatMode}.
   */
  private static @RepeatMode int fetchRepeatMode(RemoteMediaClient remoteMediaClient) {
    MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
    if (mediaStatus == null) {
      // No media session active, yet.
      return REPEAT_MODE_OFF;
    }
    int castRepeatMode = mediaStatus.getQueueRepeatMode();
    switch (castRepeatMode) {
      case MediaStatus.REPEAT_MODE_REPEAT_SINGLE:
        return REPEAT_MODE_ONE;
      case MediaStatus.REPEAT_MODE_REPEAT_ALL:
      case MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE:
        return REPEAT_MODE_ALL;
      case MediaStatus.REPEAT_MODE_REPEAT_OFF:
        return REPEAT_MODE_OFF;
      default:
        throw new IllegalStateException();
    }
  }

  private static int fetchCurrentWindowIndex(
      @Nullable RemoteMediaClient remoteMediaClient, Timeline timeline) {
    if (remoteMediaClient == null) {
      return 0;
    }

    int currentWindowIndex = C.INDEX_UNSET;
    @Nullable MediaQueueItem currentItem = remoteMediaClient.getCurrentItem();
    if (currentItem != null) {
      currentWindowIndex = timeline.getIndexOfPeriod(currentItem.getItemId());
    }
    if (currentWindowIndex == C.INDEX_UNSET) {
      // The timeline is empty. Fall back to index 0.
      currentWindowIndex = 0;
    }
    return currentWindowIndex;
  }

  private static boolean isTrackActive(long id, long[] activeTrackIds) {
    for (long activeTrackId : activeTrackIds) {
      if (activeTrackId == id) {
        return true;
      }
    }
    return false;
  }

  private static int getCastRepeatMode(@RepeatMode int repeatMode) {
    switch (repeatMode) {
      case REPEAT_MODE_ONE:
        return MediaStatus.REPEAT_MODE_REPEAT_SINGLE;
      case REPEAT_MODE_ALL:
        return MediaStatus.REPEAT_MODE_REPEAT_ALL;
      case REPEAT_MODE_OFF:
        return MediaStatus.REPEAT_MODE_REPEAT_OFF;
      default:
        throw new IllegalArgumentException();
    }
  }

  private MediaQueueItem[] toMediaQueueItems(List<MediaItem> mediaItems) {
    MediaQueueItem[] mediaQueueItems = new MediaQueueItem[mediaItems.size()];
    for (int i = 0; i < mediaItems.size(); i++) {
      mediaQueueItems[i] = mediaItemConverter.toMediaQueueItem(mediaItems.get(i));
    }
    return mediaQueueItems;
  }

  // Internal classes.

  private final class StatusListener extends RemoteMediaClient.Callback
      implements SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {

    // RemoteMediaClient.ProgressListener implementation.

    @Override
    public void onProgressUpdated(long progressMs, long unusedDurationMs) {
      lastReportedPositionMs = progressMs;
    }

    // RemoteMediaClient.Callback implementation.

    @Override
    public void onStatusUpdated() {
      updateInternalStateAndNotifyIfChanged();
    }

    @Override
    public void onMetadataUpdated() {}

    @Override
    public void onQueueStatusUpdated() {
      updateTimelineAndNotifyIfChanged();
      listeners.flushEvents();
    }

    @Override
    public void onPreloadStatusUpdated() {}

    @Override
    public void onSendingRemoteMediaRequest() {}

    @Override
    public void onAdBreakStatusUpdated() {}

    // SessionManagerListener implementation.

    @Override
    public void onSessionStarted(CastSession castSession, String s) {
      setRemoteMediaClient(castSession.getRemoteMediaClient());
    }

    @Override
    public void onSessionResumed(CastSession castSession, boolean b) {
      setRemoteMediaClient(castSession.getRemoteMediaClient());
    }

    @Override
    public void onSessionEnded(CastSession castSession, int i) {
      setRemoteMediaClient(null);
    }

    @Override
    public void onSessionSuspended(CastSession castSession, int i) {
      setRemoteMediaClient(null);
    }

    @Override
    public void onSessionResumeFailed(CastSession castSession, int statusCode) {
      Log.e(
          TAG,
          "Session resume failed. Error code "
              + statusCode
              + ": "
              + CastUtils.getLogString(statusCode));
    }

    @Override
    public void onSessionStarting(CastSession castSession) {
      // Do nothing.
    }

    @Override
    public void onSessionStartFailed(CastSession castSession, int statusCode) {
      Log.e(
          TAG,
          "Session start failed. Error code "
              + statusCode
              + ": "
              + CastUtils.getLogString(statusCode));
    }

    @Override
    public void onSessionEnding(CastSession castSession) {
      // Do nothing.
    }

    @Override
    public void onSessionResuming(CastSession castSession, String s) {
      // Do nothing.
    }
  }

  private final class SeekResultCallback implements ResultCallback<MediaChannelResult> {

    // We still call Listener#onSeekProcessed() for backwards compatibility with listeners that
    // don't implement onPositionDiscontinuity().
    @SuppressWarnings("deprecation")
    @Override
    public void onResult(MediaChannelResult result) {
      int statusCode = result.getStatus().getStatusCode();
      if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
        Log.e(
            TAG,
            "Seek failed. Error code " + statusCode + ": " + CastUtils.getLogString(statusCode));
      }
      if (--pendingSeekCount == 0) {
        currentWindowIndex = pendingSeekWindowIndex;
        pendingSeekWindowIndex = C.INDEX_UNSET;
        pendingSeekPositionMs = C.TIME_UNSET;
        listeners.sendEvent(/* eventFlag= */ C.INDEX_UNSET, Listener::onSeekProcessed);
      }
    }
  }

  /** Holds the value and the masking status of a specific part of the {@link CastPlayer} state. */
  private static final class StateHolder<T> {

    /** The user-facing value of a specific part of the {@link CastPlayer} state. */
    public T value;

    /**
     * If {@link #value} is being masked, holds the result callback for the operation that triggered
     * the masking. Or null if {@link #value} is not being masked.
     */
    @Nullable public ResultCallback<MediaChannelResult> pendingResultCallback;

    public StateHolder(T initialValue) {
      value = initialValue;
    }

    public void clearPendingResultCallback() {
      pendingResultCallback = null;
    }

    /**
     * Returns whether this state holder accepts updates coming from the given result callback.
     *
     * <p>A null {@code resultCallback} means that the update is a regular receiver state update, in
     * which case the update will only be accepted if {@link #value} is not being masked. If {@link
     * #value} is being masked, the update will only be accepted if {@code resultCallback} is the
     * same as the {@link #pendingResultCallback}.
     *
     * @param resultCallback A result callback. May be null if the update comes from a regular
     *     receiver status update.
     */
    public boolean acceptsUpdate(@Nullable ResultCallback<?> resultCallback) {
      return pendingResultCallback == resultCallback;
    }
  }
}
