MediaController.java

/*
 * Copyright 2019 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.media2.session;

import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
import static androidx.media2.common.SessionPlayer.BUFFERING_STATE_UNKNOWN;
import static androidx.media2.common.SessionPlayer.PLAYER_STATE_IDLE;
import static androidx.media2.common.SessionPlayer.REPEAT_MODE_NONE;
import static androidx.media2.common.SessionPlayer.SHUFFLE_MODE_NONE;
import static androidx.media2.common.SessionPlayer.UNKNOWN_TIME;

import android.app.PendingIntent;
import android.content.Context;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.ResultReceiver;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.text.TextUtils;
import android.util.Log;
import android.view.Surface;

import androidx.annotation.GuardedBy;
import androidx.annotation.IntDef;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.concurrent.ListenableFuture;
import androidx.core.util.ObjectsCompat;
import androidx.core.util.Pair;
import androidx.media.AudioAttributesCompat;
import androidx.media.VolumeProviderCompat;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.Rating;
import androidx.media2.common.SessionPlayer;
import androidx.media2.common.SessionPlayer.RepeatMode;
import androidx.media2.common.SessionPlayer.ShuffleMode;
import androidx.media2.common.SessionPlayer.TrackInfo;
import androidx.media2.common.SubtitleData;
import androidx.media2.common.VideoSize;
import androidx.media2.session.MediaSession.CommandButton;
import androidx.versionedparcelable.ParcelField;
import androidx.versionedparcelable.VersionedParcelable;
import androidx.versionedparcelable.VersionedParcelize;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;

/**
 * Allows an app to interact with an active {@link MediaSession} or a
 * {@link MediaSessionService} which would provide {@link MediaSession}. Media buttons and other
 * commands can be sent to the session.
 * <p>
 * MediaController objects are thread-safe.
 * <p>
 * Topic covered here:
 * <ol>
 * <li><a href="#ControllerLifeCycle">Controller Lifecycle</a>
 * </ol>
 * <a name="ControllerLifeCycle"></a>
 * <h3>Controller Lifecycle</h3>
 * <p>
 * When a controller is created with the {@link SessionToken} for a {@link MediaSession} (i.e.
 * session token type is {@link SessionToken#TYPE_SESSION}), the controller will connect to the
 * specific session.
 * <p>
 * When a controller is created with the {@link SessionToken} for a {@link MediaSessionService}
 * (i.e. session token type is {@link SessionToken#TYPE_SESSION_SERVICE} or
 * {@link SessionToken#TYPE_LIBRARY_SERVICE}), the controller binds to the service for connecting
 * to a {@link MediaSession} in it. {@link MediaSessionService} will provide a session to connect.
 * <p>
 * When a controller connects to a session,
 * {@link MediaSession.SessionCallback#onConnect(MediaSession, MediaSession.ControllerInfo)}
 * will be called to either accept or reject the connection. Wait
 * {@link ControllerCallback#onConnected(MediaController, SessionCommandGroup)} or
 * {@link ControllerCallback#onDisconnected(MediaController)} for the result.
 * <p>
 * When the connected session is closed, the controller will receive
 * {@link ControllerCallback#onDisconnected(MediaController)}.
 * <p>
 * When you're done, use {@link #close()} to clean up resources. This also helps session service
 * to be destroyed when there's no controller associated with it.
 *
 * @see MediaSession
 * @see MediaSessionService
 */
public class MediaController implements AutoCloseable {
    private static final String TAG = "MediaController";

    /**
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    @IntDef({AudioManager.ADJUST_LOWER, AudioManager.ADJUST_RAISE, AudioManager.ADJUST_SAME,
            AudioManager.ADJUST_MUTE, AudioManager.ADJUST_UNMUTE, AudioManager.ADJUST_TOGGLE_MUTE})
    @Retention(RetentionPolicy.SOURCE)
    public @interface VolumeDirection {}

    /**
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    @IntDef(value = {AudioManager.FLAG_SHOW_UI, AudioManager.FLAG_ALLOW_RINGER_MODES,
            AudioManager.FLAG_PLAY_SOUND, AudioManager.FLAG_REMOVE_SOUND_AND_VIBRATE,
            AudioManager.FLAG_VIBRATE}, flag = true)
    @Retention(RetentionPolicy.SOURCE)
    public @interface VolumeFlags {}

    final Object mLock = new Object();
    @GuardedBy("mLock")
    MediaControllerImpl mImpl;
    @GuardedBy("mLock")
    boolean mClosed;

    final ControllerCallback mCallback;
    final Executor mCallbackExecutor;

    @GuardedBy("mLock")
    private final List<Pair<ControllerCallback, Executor>> mExtraCallbacks = new ArrayList<>();

    // For testing.
    Long mTimeDiff;

    /**
     * Create a {@link MediaController} from the {@link SessionToken}.
     *
     * @param context Context
     * @param token token to connect to
     * @param executor executor to run callbacks on.
     * @param callback controller callback to receive changes in
     */
    MediaController(@NonNull final Context context, @NonNull final SessionToken token,
            @Nullable Bundle connectionHints, @Nullable Executor executor,
            @Nullable ControllerCallback callback) {
        if (context == null) {
            throw new NullPointerException("context shouldn't be null");
        }
        if (token == null) {
            throw new NullPointerException("token shouldn't be null");
        }
        mCallback = callback;
        mCallbackExecutor = executor;
        synchronized (mLock) {
            mImpl = createImpl(context, token, connectionHints);
        }
    }

    /**
     * Create a {@link MediaController} from the {@link MediaSessionCompat.Token}.
     *
     * @param context Context
     * @param token token to connect to
     * @param executor executor to run callbacks on.
     * @param callback controller callback to receive changes in
     */
    MediaController(@NonNull final Context context, @NonNull final MediaSessionCompat.Token token,
            @Nullable final Bundle connectionHints, @Nullable final Executor executor,
            @Nullable final ControllerCallback callback) {
        if (context == null) {
            throw new NullPointerException("context shouldn't be null");
        }
        if (token == null) {
            throw new NullPointerException("token shouldn't be null");
        }
        mCallback = callback;
        mCallbackExecutor = executor;
        SessionToken.createSessionToken(context, token, executor,
                new SessionToken.OnSessionTokenCreatedListener() {
                    @Override
                    public void onSessionTokenCreated(MediaSessionCompat.Token token,
                            SessionToken token2) {
                        synchronized (mLock) {
                            if (!mClosed) {
                                mImpl = createImpl(context, token2, connectionHints);
                            } else {
                                notifyControllerCallback(new ControllerCallbackRunnable() {
                                    @Override
                                    public void run(@NonNull ControllerCallback callback) {
                                        callback.onDisconnected(MediaController.this);
                                    }
                                });
                            }
                        }
                    }
                });
    }

    MediaControllerImpl createImpl(@NonNull Context context, @NonNull SessionToken token,
            @Nullable Bundle connectionHints) {
        if (token.isLegacySession()) {
            return new MediaControllerImplLegacy(context, this, token);
        } else {
            return new MediaControllerImplBase(context, this, token, connectionHints);
        }
    }

    MediaControllerImpl getImpl() {
        synchronized (mLock) {
            return mImpl;
        }
    }

    /**
     * Release this object, and disconnect from the session. After this, callbacks wouldn't be
     * received.
     */
    @Override
    public void close() {
        try {
            MediaControllerImpl impl;
            synchronized (mLock) {
                if (mClosed) {
                    return;
                }
                mClosed = true;
                impl = mImpl;
            }
            if (impl != null) {
                impl.close();
            }
        } catch (Exception e) {
            // Should not be here.
        }
    }

    /**
     * Returns {@link SessionToken} of the connected session.
     * If it is not connected yet, it returns {@code null}.
     * <p>
     * This may differ with the {@link SessionToken} from the constructor. For example, if the
     * controller is created with the token for {@link MediaSessionService}, this would return
     * token for the {@link MediaSession} in the service.
     *
     * @return SessionToken of the connected session, or {@code null} if not connected
     */
    @Nullable
    public SessionToken getConnectedToken() {
        return isConnected() ? getImpl().getConnectedToken() : null;
    }

    /**
     * Returns whether this class is connected to active {@link MediaSession} or not.
     */
    public boolean isConnected() {
        MediaControllerImpl impl = getImpl();
        return impl != null && impl.isConnected();
    }

    /**
     * Requests that the player starts or resumes playback.
     * <p>
     * If the player state is {@link SessionPlayer#PLAYER_STATE_IDLE}, the session would also call
     * {@link SessionPlayer#prepare} and then {@link SessionPlayer#play} to start playback. If you
     * want to have finer grained control of the playback start call {@link #prepare} manually
     * before this. Calling {@link #prepare} in advance would help this method to start playback
     * faster and also help to take audio focus at the last moment.
     *
     * @see #prepare
     */
    @NonNull
    public ListenableFuture<SessionResult> play() {
        if (isConnected()) {
            return getImpl().play();
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests that the player pauses playback.
     * <p>
     * This would transfer the player state from {@link SessionPlayer#PLAYER_STATE_PLAYING} to
     * {@link SessionPlayer#PLAYER_STATE_PAUSED}.
     */
    @NonNull
    public ListenableFuture<SessionResult> pause() {
        if (isConnected()) {
            return getImpl().pause();
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests that the player prepares the media items for playback.
     * <p>
     * This would transfer the player state from {@link SessionPlayer#PLAYER_STATE_IDLE} to
     * {@link SessionPlayer#PLAYER_STATE_PAUSED}.
     * <p>
     * Playback can be started without this. But this provides finer grained control of playback
     * start. See {@link #play} for details.
     *
     * @see #play
     */
    @NonNull
    public ListenableFuture<SessionResult> prepare() {
        if (isConnected()) {
            return getImpl().prepare();
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests session to increase the playback speed.
     *
     * @see MediaSession.SessionCallback#onFastForward(MediaSession, MediaSession.ControllerInfo)
     */
    @NonNull
    public ListenableFuture<SessionResult> fastForward() {
        if (isConnected()) {
            return getImpl().fastForward();
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests session to decrease the playback speed.
     *
     * @see MediaSession.SessionCallback#onRewind(MediaSession, MediaSession.ControllerInfo)
     */
    @NonNull
    public ListenableFuture<SessionResult> rewind() {
        if (isConnected()) {
            return getImpl().rewind();
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests session to skip backward within the current media item.
     *
     * @see MediaSession.SessionCallback#onSkipForward(MediaSession, MediaSession.ControllerInfo)
     */
    @NonNull
    public ListenableFuture<SessionResult> skipForward() {
        // To match with KEYCODE_MEDIA_SKIP_FORWARD
        if (isConnected()) {
            return getImpl().skipForward();
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests session to skip forward within the current media item.
     *
     * @see MediaSession.SessionCallback#onSkipBackward(MediaSession, MediaSession.ControllerInfo)
     */
    @NonNull
    public ListenableFuture<SessionResult> skipBackward() {
        // To match with KEYCODE_MEDIA_SKIP_BACKWARD
        if (isConnected()) {
            return getImpl().skipBackward();
        }
        return createDisconnectedFuture();
    }

    /**
     * Move to a new location in the media stream.
     *
     * @param pos Position to move to, in milliseconds.
     */
    @NonNull
    public ListenableFuture<SessionResult> seekTo(long pos) {
        if (isConnected()) {
            return getImpl().seekTo(pos);
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests that the player start playback for a specific media id.
     *
     * @param mediaId The non-empty media id
     * @param extras Optional extras that can include extra information about the media item
     *               to be played.
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    @NonNull
    public ListenableFuture<SessionResult> playFromMediaId(@NonNull String mediaId,
            @Nullable Bundle extras) {
        if (TextUtils.isEmpty(mediaId)) {
            throw new IllegalArgumentException("mediaId shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().playFromMediaId(mediaId, extras);
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests that the player start playback for a specific search query.
     *
     * @param query The non-empty search query
     * @param extras Optional extras that can include extra information about the query.
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    @NonNull
    public ListenableFuture<SessionResult> playFromSearch(@NonNull String query,
            @Nullable Bundle extras) {
        if (TextUtils.isEmpty(query)) {
            throw new IllegalArgumentException("query shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().playFromSearch(query, extras);
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests that the player start playback for a specific {@link Uri}.
     *
     * @param uri The URI of the requested media.
     * @param extras Optional extras that can include extra information about the media item
     *               to be played.
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    @NonNull
    public ListenableFuture<SessionResult> playFromUri(@NonNull Uri uri,
            @Nullable Bundle extras) {
        if (uri == null) {
            throw new NullPointerException("uri shouldn't be null");
        }
        if (isConnected()) {
            return getImpl().playFromUri(uri, extras);
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests that the player prepare a media item with the media id for playback.
     * In other words, other sessions can continue to play during the preparation of this session.
     * This method can be used to speed up the start of the playback.
     * Once the prepare is done, the session will change its playback state to
     * {@link SessionPlayer#PLAYER_STATE_PAUSED}. Afterwards, {@link #play} can be called to start
     * playback. If the prepare is not needed, {@link #playFromMediaId} can be directly called
     * without this method.
     *
     * @param mediaId The non-empty media id
     * @param extras Optional extras that can include extra information about the media item
     *               to be prepared.
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    @NonNull
    public ListenableFuture<SessionResult> prepareFromMediaId(@NonNull String mediaId,
            @Nullable Bundle extras) {
        if (TextUtils.isEmpty(mediaId)) {
            throw new IllegalArgumentException("mediaId shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().prepareFromMediaId(mediaId, extras);
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests that the player prepare a media item with the specific search query for playback.
     * In other words, other sessions can continue to play during the preparation of this session.
     * This method can be used to speed up the start of the playback.
     * Once the prepare is done, the session will change its playback state to
     * {@link SessionPlayer#PLAYER_STATE_PAUSED}. Afterwards, {@link #play} can be called to start
     * playback. If the prepare is not needed, {@link #playFromSearch} can be directly called
     * without this method.
     *
     * @param query The non-empty search query
     * @param extras Optional extras that can include extra information about the query.
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    @NonNull
    public ListenableFuture<SessionResult> prepareFromSearch(@NonNull String query,
            @Nullable Bundle extras) {
        if (TextUtils.isEmpty(query)) {
            throw new IllegalArgumentException("query shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().prepareFromSearch(query, extras);
        }
        return createDisconnectedFuture();
    }

    /**
     * Requests that the player prepare a media item with the specific {@link Uri} for playback.
     * In other words, other sessions can continue to play during the preparation of this session.
     * This method can be used to speed up the start of the playback.
     * Once the prepare is done, the session will change its playback state to
     * {@link SessionPlayer#PLAYER_STATE_PAUSED}. Afterwards, {@link #play} can be called to start
     * playback. If the prepare is not needed, {@link #playFromUri} can be directly called
     * without this method.
     *
     * @param uri The URI of the requested media.
     * @param extras Optional extras that can include extra information about the media item
     *               to be prepared.
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    @NonNull
    public ListenableFuture<SessionResult> prepareFromUri(@NonNull Uri uri,
            @Nullable Bundle extras) {
        if (uri == null) {
            throw new NullPointerException("uri shouldn't be null");
        }
        if (isConnected()) {
            return getImpl().prepareFromUri(uri, extras);
        }
        return createDisconnectedFuture();
    }

    /**
     * Set the volume of the output this session is playing on. The command will be ignored if it
     * does not support {@link VolumeProviderCompat#VOLUME_CONTROL_ABSOLUTE}.
     * <p>
     * If the session is local playback, this changes the device's volume with the stream that
     * session's player is using. Flags will be specified for the {@link AudioManager}.
     * <p>
     * If the session is remote player (i.e. session has set volume provider), its volume provider
     * will receive this request instead.
     *
     * @see #getPlaybackInfo()
     * @param value The value to set it to, between 0 and the reported max.
     * @param flags flags from {@link AudioManager} to include with the volume request for local
     *              playback
     */
    @NonNull
    public ListenableFuture<SessionResult> setVolumeTo(int value, @VolumeFlags int flags) {
        if (isConnected()) {
            return getImpl().setVolumeTo(value, flags);
        }
        return createDisconnectedFuture();
    }

    /**
     * Adjust the volume of the output this session is playing on. The direction
     * must be one of {@link AudioManager#ADJUST_LOWER},
     * {@link AudioManager#ADJUST_RAISE}, or {@link AudioManager#ADJUST_SAME}.
     * <p>
     * The command will be ignored if the session does not support
     * {@link VolumeProviderCompat#VOLUME_CONTROL_RELATIVE} or
     * {@link VolumeProviderCompat#VOLUME_CONTROL_ABSOLUTE}.
     * <p>
     * If the session is local playback, this changes the device's volume with the stream that
     * session's player is using. Flags will be specified for the {@link AudioManager}.
     * <p>
     * If the session is remote player (i.e. session has set volume provider), its volume provider
     * will receive this request instead.
     *
     * @see #getPlaybackInfo()
     * @param direction The direction to adjust the volume in.
     * @param flags flags from {@link AudioManager} to include with the volume request for local
     *              playback
     */
    @NonNull
    public ListenableFuture<SessionResult> adjustVolume(@VolumeDirection int direction,
            @VolumeFlags int flags) {
        if (isConnected()) {
            return getImpl().adjustVolume(direction, flags);
        }
        return createDisconnectedFuture();
    }

    /**
     * Get an intent for launching UI associated with this session if one exists.
     * If it is not connected yet, it returns {@code null}.
     *
     * @return A {@link PendingIntent} to launch UI or null
     */
    @Nullable
    public PendingIntent getSessionActivity() {
        return isConnected() ? getImpl().getSessionActivity() : null;
    }

    /**
     * Get the lastly cached player state from
     * {@link ControllerCallback#onPlayerStateChanged(MediaController, int)}.
     * If it is not connected yet, it returns {@link SessionPlayer#PLAYER_STATE_IDLE}.
     *
     * @return player state
     */
    public int getPlayerState() {
        return isConnected() ? getImpl().getPlayerState() : PLAYER_STATE_IDLE;
    }

    /**
     * Gets the duration of the current media item, or {@link SessionPlayer#UNKNOWN_TIME} if
     * unknown or not connected.
     *
     * @return the duration in ms, or {@link SessionPlayer#UNKNOWN_TIME}
     */
    public long getDuration() {
        return isConnected() ? getImpl().getDuration() : UNKNOWN_TIME;
    }

    /**
     * Gets the current playback position.
     * <p>
     * This returns the calculated value of the position, based on the difference between the
     * update time and current time.
     *
     * @return the current playback position in ms, or {@link SessionPlayer#UNKNOWN_TIME}
     *         if unknown or not connected
     */
    public long getCurrentPosition() {
        return isConnected() ? getImpl().getCurrentPosition() : UNKNOWN_TIME;
    }

    /**
     * Get the lastly cached playback speed from
     * {@link ControllerCallback#onPlaybackSpeedChanged(MediaController, float)}.
     *
     * @return speed the lastly cached playback speed, or 0f if unknown or not connected
     */
    public float getPlaybackSpeed() {
        return isConnected() ? getImpl().getPlaybackSpeed() : 0f;
    }

    /**
     * Sets the playback speed. A value of {@code 1.0f} is the default playback value,
     * and a negative value indicates reverse playback. {@code 0.0f} is not allowed.
     *
     * @throws IllegalArgumentException if the {@code speed} is equal to zero.
     */
    @NonNull
    public ListenableFuture<SessionResult> setPlaybackSpeed(float speed) {
        if (speed == 0.0f) {
            throw new IllegalArgumentException("speed must not be zero");
        }
        if (isConnected()) {
            return getImpl().setPlaybackSpeed(speed);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the current buffering state of the player.
     * During buffering, see {@link #getBufferedPosition()} for the quantifying the amount already
     * buffered.
     *
     * @return the buffering state, or {@link SessionPlayer#BUFFERING_STATE_UNKNOWN}
     *         if unknown or not connected
     */
    @SessionPlayer.BuffState
    public int getBufferingState() {
        return isConnected() ? getImpl().getBufferingState() : BUFFERING_STATE_UNKNOWN;
    }

    /**
     * Gets the lastly cached buffered position from the session when
     * {@link ControllerCallback#onBufferingStateChanged(MediaController, MediaItem, int)} is
     * called.
     *
     * @return buffering position in millis, or {@link SessionPlayer#UNKNOWN_TIME} if
     *         unknown or not connected
     */
    public long getBufferedPosition() {
        return isConnected() ? getImpl().getBufferedPosition() : UNKNOWN_TIME;
    }

    /**
     * Get the current playback info for this session.
     * If it is not connected yet, it returns {@code null}.
     *
     * @return The current playback info or null
     */
    @Nullable
    public PlaybackInfo getPlaybackInfo() {
        return isConnected() ? getImpl().getPlaybackInfo() : null;
    }

    /**
     * Rate the media. This will cause the rating to be set for the current user.
     * The rating style must follow the user rating style from the session.
     * You can get the rating style from the session through the
     * {@link MediaMetadata#getRating(String)} with the key
     * {@link MediaMetadata#METADATA_KEY_USER_RATING}.
     * <p>
     * If the user rating was {@code null}, the media item does not accept setting user rating.
     *
     * @param mediaId The non-empty media id
     * @param rating The rating to set
     */
    @NonNull
    public ListenableFuture<SessionResult> setRating(@NonNull String mediaId,
            @NonNull Rating rating) {
        if (mediaId == null) {
            throw new NullPointerException("mediaId shouldn't be null");
        } else if (TextUtils.isEmpty(mediaId)) {
            throw new IllegalArgumentException("mediaId shouldn't be empty");
        }
        if (rating == null) {
            throw new NullPointerException("rating shouldn't be null");
        }
        if (isConnected()) {
            return getImpl().setRating(mediaId, rating);
        }
        return createDisconnectedFuture();
    }

    /**
     * Send custom command to the session
     * <p>
     * Interoperability: When connected to
     * {@link android.support.v4.media.session.MediaSessionCompat},
     * {@link SessionResult#getResultCode()} will return the custom result code from the
     * {@link ResultReceiver#onReceiveResult(int, Bundle)} instead of the standard result codes
     * defined in the {@link SessionResult}.
     * <p>
     * A command is not accepted if it is not a custom command.
     *
     * @param command custom command
     * @param args optional argument
     */
    @NonNull
    public ListenableFuture<SessionResult> sendCustomCommand(@NonNull SessionCommand command,
            @Nullable Bundle args) {
        if (command == null) {
            throw new NullPointerException("command shouldn't be null");
        }
        if (command.getCommandCode() != SessionCommand.COMMAND_CODE_CUSTOM) {
            throw new IllegalArgumentException("command should be a custom command");
        }
        if (isConnected()) {
            return getImpl().sendCustomCommand(command, args);
        }
        return createDisconnectedFuture();
    }

    /**
     * Returns the cached playlist from {@link ControllerCallback#onPlaylistChanged}. Can be
     * {@code null} if the playlist hasn't been set or it's reset by {@link #setMediaItem}.
     * <p>
     * This list may differ with the list that was specified with
     * {@link #setPlaylist(List, MediaMetadata)} depending on the {@link SessionPlayer}
     * implementation. Use media items returned here for other playlist agent APIs such as
     * {@link SessionPlayer#skipToPlaylistItem}.
     *
     * @return playlist, or {@code null} if the playlist hasn't been set, controller isn't
     *         connected, or it doesn't have enough permission.
     * @see SessionCommand#COMMAND_CODE_PLAYER_GET_PLAYLIST
     */
    @Nullable
    public List<MediaItem> getPlaylist() {
        return isConnected() ? getImpl().getPlaylist() : null;
    }

    /**
     * Sets the playlist with the list of media IDs. Use this or {@link #setMediaItem} to specify
     * which items to play.
     * <p>
     * All media IDs in the list shouldn't be an empty string.
     * <p>
     * The {@link ControllerCallback#onPlaylistChanged} and
     * {@link ControllerCallback#onCurrentMediaItemChanged} would be called when it's completed.
     * The current item would be the first item in the playlist.
     *
     * @param list list of media id. Shouldn't contain an empty id.
     * @param metadata metadata of the playlist
     * @see #setMediaItem
     * @see ControllerCallback#onCurrentMediaItemChanged
     * @see ControllerCallback#onPlaylistChanged
     * @see MediaMetadata#METADATA_KEY_MEDIA_ID
     * @throws IllegalArgumentException if the list is {@code null} or contains any empty string.
     */
    @NonNull
    public ListenableFuture<SessionResult> setPlaylist(@NonNull List<String> list,
            @Nullable MediaMetadata metadata) {
        if (list == null) {
            throw new NullPointerException("list shouldn't be null");
        }
        for (int i = 0; i < list.size(); i++) {
            if (TextUtils.isEmpty(list.get(i))) {
                throw new IllegalArgumentException("list shouldn't contain empty id, index=" + i);
            }
        }
        if (isConnected()) {
            return getImpl().setPlaylist(list, metadata);
        }
        return createDisconnectedFuture();
    }

    /**
     * Sets a {@link MediaItem} for playback with the media ID. Use this or {@link #setPlaylist}
     * to specify which items to play. If you want to change current item in the playlist, use one
     * of {@link #skipToPlaylistItem}, {@link #skipToNextPlaylistItem}, or
     * {@link #skipToPreviousPlaylistItem} instead of this method.
     * <p>
     * The {@link ControllerCallback#onPlaylistChanged} and
     * {@link ControllerCallback#onCurrentMediaItemChanged} would be called when it's completed.
     * The current item would be the item given here.
     *
     * @param mediaId The non-empty media id of the item to play
     * @see #setPlaylist
     * @see ControllerCallback#onCurrentMediaItemChanged
     * @see ControllerCallback#onPlaylistChanged
     * @see MediaMetadata#METADATA_KEY_MEDIA_ID
     */
    @NonNull
    public ListenableFuture<SessionResult> setMediaItem(@NonNull String mediaId) {
        if (TextUtils.isEmpty(mediaId)) {
            throw new IllegalArgumentException("mediaId shouldn't be empty");
        }
        if (isConnected()) {
            getImpl().setMediaItem(mediaId);
        }
        return createDisconnectedFuture();
    }

    /**
     * Updates the playlist metadata
     *
     * @param metadata metadata of the playlist
     */
    @NonNull
    public ListenableFuture<SessionResult> updatePlaylistMetadata(
            @Nullable MediaMetadata metadata) {
        if (isConnected()) {
            return getImpl().updatePlaylistMetadata(metadata);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the lastly cached playlist metadata either from
     * {@link ControllerCallback#onPlaylistMetadataChanged} or
     * {@link ControllerCallback#onPlaylistChanged}.
     *
     * @return metadata metadata of the playlist, or null if none is set or the controller is not
     *         connected
     */
    @Nullable
    public MediaMetadata getPlaylistMetadata() {
        return isConnected() ? getImpl().getPlaylistMetadata() : null;
    }

    /**
     * Adds the media item to the playlist at the index with the media ID. Index equals or greater
     * than the current playlist size (e.g. {@link Integer#MAX_VALUE}) will add the item at the end
     * of the playlist.
     * <p>
     * This will not change the currently playing media item.
     * If index is less than or equal to the current index of the playlist,
     * the current index of the playlist will be incremented correspondingly.
     *
     * @param index the index you want to add
     * @param mediaId The non-empty media id of the new item
     * @see MediaMetadata#METADATA_KEY_MEDIA_ID
     */
    @NonNull
    public ListenableFuture<SessionResult> addPlaylistItem(@IntRange(from = 0) int index,
            @NonNull String mediaId) {
        if (index < 0) {
            throw new IllegalArgumentException("index shouldn't be negative");
        }
        if (TextUtils.isEmpty(mediaId)) {
            throw new IllegalArgumentException("mediaId shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().addPlaylistItem(index, mediaId);
        }
        return createDisconnectedFuture();
    }

    /**
     * Removes the media item at index in the playlist.
     * <p>
     * If the item is the currently playing item of the playlist, current playback
     * will be stopped and playback moves to next source in the list.
     *
     * @param index the media item you want to add
     */
    @NonNull
    public ListenableFuture<SessionResult> removePlaylistItem(@IntRange(from = 0) int index) {
        if (index < 0) {
            throw new IllegalArgumentException("index shouldn't be negative");
        }
        if (isConnected()) {
            return getImpl().removePlaylistItem(index);
        }
        return createDisconnectedFuture();
    }

    /**
     * Replaces the media item at index in the playlist with the media ID.
     *
     * @param index the index of the item to replace
     * @param mediaId The non-empty media id of the new item
     * @see MediaMetadata#METADATA_KEY_MEDIA_ID
     */
    @NonNull
    public ListenableFuture<SessionResult> replacePlaylistItem(@IntRange(from = 0) int index,
            @NonNull String mediaId) {
        if (index < 0) {
            throw new IllegalArgumentException("index shouldn't be negative");
        }
        if (TextUtils.isEmpty(mediaId)) {
            throw new IllegalArgumentException("mediaId shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().replacePlaylistItem(index, mediaId);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the lastly cached current item from
     * {@link ControllerCallback#onCurrentMediaItemChanged(MediaController, MediaItem)}.
     *
     * @return the currently playing item, or null if unknown or not connected
     * @see #setMediaItem
     * @see #setPlaylist
     */
    @Nullable
    public MediaItem getCurrentMediaItem() {
        return isConnected() ? getImpl().getCurrentMediaItem() : null;
    }

    /**
     * Gets the current item index in the playlist. The returned value can be outdated after
     * {@link ControllerCallback#onCurrentMediaItemChanged(MediaController, MediaItem)} or
     * {@link ControllerCallback#onPlaylistChanged(MediaController, List, MediaMetadata)} is called.
     *
     * @return the index of current item in playlist, or -1 if current media item does not exist or
     * playlist hasn't been set.
     */
    public int getCurrentMediaItemIndex() {
        return isConnected() ? getImpl().getCurrentMediaItemIndex() : -1;
    }

    /**
     * Gets the previous item index in the playlist. The returned value can be outdated after
     * {@link ControllerCallback#onCurrentMediaItemChanged(MediaController, MediaItem)} or
     * {@link ControllerCallback#onPlaylistChanged(MediaController, List, MediaMetadata)} is called.
     * <p>
     * Interoperability: When connected to
     * {@link android.support.v4.media.session.MediaSessionCompat}, this will always return
     * {@code -1}.
     *
     * @return the index of previous item in playlist, or -1 if previous media item does not exist
     * or playlist hasn't been set.
     */
    public int getPreviousMediaItemIndex() {
        return isConnected() ? getImpl().getPreviousMediaItemIndex() : -1;
    }

    /**
     * Gets the next item index in the playlist. The returned value can be outdated after
     * {@link ControllerCallback#onCurrentMediaItemChanged(MediaController, MediaItem)} or
     * {@link ControllerCallback#onPlaylistChanged(MediaController, List, MediaMetadata)} is called.
     * <p>
     * Interoperability: When connected to
     * {@link android.support.v4.media.session.MediaSessionCompat}, this will always return
     * {@code -1}.
     *
     * @return the index of next item in playlist, or -1 if next media item does not exist or
     * playlist hasn't been set.
     */
    public int getNextMediaItemIndex() {
        return isConnected() ? getImpl().getNextMediaItemIndex() : -1;
    }

    /**
     * Skips to the previous item in the playlist.
     * <p>
     * This calls {@link SessionPlayer#skipToPreviousPlaylistItem()}.
     */
    @NonNull
    public ListenableFuture<SessionResult> skipToPreviousPlaylistItem() {
        if (isConnected()) {
            return getImpl().skipToPreviousItem();
        }
        return createDisconnectedFuture();
    }

    /**
     * Skips to the next item in the playlist.
     * <p>
     * This calls {@link SessionPlayer#skipToNextPlaylistItem()}.
     */
    @NonNull
    public ListenableFuture<SessionResult> skipToNextPlaylistItem() {
        if (isConnected()) {
            return getImpl().skipToNextItem();
        }
        return createDisconnectedFuture();
    }

    /**
     * Skips to the item in the playlist at the index.
     * <p>
     * This calls {@link SessionPlayer#skipToPlaylistItem(int)}.
     *
     * @param index The item in the playlist you want to play
     */
    @NonNull
    public ListenableFuture<SessionResult> skipToPlaylistItem(@IntRange(from = 0) int index) {
        if (index < 0) {
            throw new IllegalArgumentException("index shouldn't be negative");
        }
        if (isConnected()) {
            return getImpl().skipToPlaylistItem(index);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the cached repeat mode from the {@link ControllerCallback#onRepeatModeChanged}.
     * If it is not connected yet, it returns {@link SessionPlayer#REPEAT_MODE_NONE}.
     *
     * @return repeat mode
     * @see SessionPlayer#REPEAT_MODE_NONE
     * @see SessionPlayer#REPEAT_MODE_ONE
     * @see SessionPlayer#REPEAT_MODE_ALL
     * @see SessionPlayer#REPEAT_MODE_GROUP
     */
    @RepeatMode
    public int getRepeatMode() {
        return isConnected() ? getImpl().getRepeatMode() : REPEAT_MODE_NONE;
    }

    /**
     * Sets the repeat mode.
     *
     * @param repeatMode repeat mode
     * @see SessionPlayer#REPEAT_MODE_NONE
     * @see SessionPlayer#REPEAT_MODE_ONE
     * @see SessionPlayer#REPEAT_MODE_ALL
     * @see SessionPlayer#REPEAT_MODE_GROUP
     */
    @NonNull
    public ListenableFuture<SessionResult> setRepeatMode(@RepeatMode int repeatMode) {
        if (isConnected()) {
            return getImpl().setRepeatMode(repeatMode);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the cached shuffle mode from the {@link ControllerCallback#onShuffleModeChanged}.
     * If it is not connected yet, it returns {@link SessionPlayer#SHUFFLE_MODE_NONE}.
     *
     * @return The shuffle mode
     * @see SessionPlayer#SHUFFLE_MODE_NONE
     * @see SessionPlayer#SHUFFLE_MODE_ALL
     * @see SessionPlayer#SHUFFLE_MODE_GROUP
     */
    @ShuffleMode
    public int getShuffleMode() {
        return isConnected() ? getImpl().getShuffleMode() : SHUFFLE_MODE_NONE;
    }

    /**
     * Sets the shuffle mode.
     *
     * @param shuffleMode The shuffle mode
     * @see SessionPlayer#SHUFFLE_MODE_NONE
     * @see SessionPlayer#SHUFFLE_MODE_ALL
     * @see SessionPlayer#SHUFFLE_MODE_GROUP
     */
    @NonNull
    public ListenableFuture<SessionResult> setShuffleMode(@ShuffleMode int shuffleMode) {
        if (isConnected()) {
            return getImpl().setShuffleMode(shuffleMode);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the cached video size from the {@link ControllerCallback#onVideoSizeChanged}.
     * If it is not connected yet, it returns {@code new VideoSize(0, 0)}.
     *
     * @return The video size
     *
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    @NonNull
    public VideoSize getVideoSize() {
        return isConnected() ? getImpl().getVideoSize() : new VideoSize(0, 0);
    }

    /**
     * Sets the {@link Surface} to be used as the sink for the video portion of the media.
     * <p>
     * This calls {@link SessionPlayer#setSurfaceInternal(Surface)}.
     *
     * @param surface The {@link Surface} to be used for the video portion of the media.
     * @return a {@link ListenableFuture} which represents the pending completion of the command.
     *
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    public ListenableFuture<SessionResult> setSurface(@Nullable Surface surface) {
        if (isConnected()) {
            return getImpl().setSurface(surface);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the cached track info list from the
     * {@link ControllerCallback#onTrackInfoChanged(MediaController, List)}.
     * The types of tracks supported may vary based on player implementation.
     * If it is not connected yet, it returns null.
     *
     * @return List of tracks. The total number of tracks is the size of the list.
     *
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    @Nullable
    public List<TrackInfo> getTrackInfo() {
        return isConnected() ? getImpl().getTrackInfo() : null;
    }

    /**
     * Selects the {@link TrackInfo} for the current media item.
     * The types of tracks supported may vary based on player implementation.
     *
     * @param trackInfo track to be selected.
     *
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    @NonNull
    public ListenableFuture<SessionResult> selectTrack(@NonNull TrackInfo trackInfo) {
        if (trackInfo == null) {
            throw new NullPointerException("TrackInfo shouldn't be null");
        }
        return isConnected() ? getImpl().selectTrack(trackInfo) : createDisconnectedFuture();
    }

    /**
     * Deselects the {@link TrackInfo} for the current media item.
     * The types of tracks supported may vary based on player implementation.
     *
     * @param trackInfo track to be deselected.
     *
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    @NonNull
    public ListenableFuture<SessionResult> deselectTrack(@NonNull TrackInfo trackInfo) {
        if (trackInfo == null) {
            throw new NullPointerException("TrackInfo shouldn't be null");
        }
        return isConnected() ? getImpl().deselectTrack(trackInfo) : createDisconnectedFuture();
    }

    /**
     * Gets the currently selected track for the given {@link TrackInfo.MediaTrackType}. The return
     * value is an element in the list returned by {@link #getTrackInfo()} and supported track types
     * may vary based on the player implementation.
     *
     * The returned value can be outdated after
     * {@link ControllerCallback#onTrackInfoChanged(MediaController, List)},
     * {@link ControllerCallback#onTrackSelected(MediaController, TrackInfo)},
     * or {@link ControllerCallback#onTrackDeselected(MediaController, TrackInfo)} is called.
     *
     * If it is not connected yet, it returns null.
     *
     * @param trackType type of selected track
     * @return selected track info
     *
     * @hide
     */
    // TODO: revise the method document once subtitle track support is re-enabled. (b/130312596)
    @RestrictTo(LIBRARY_GROUP)
    @Nullable
    public TrackInfo getSelectedTrack(@TrackInfo.MediaTrackType int trackType) {
        return isConnected() ? getImpl().getSelectedTrack(trackType) : null;
    }

    /**
     * Sets the time diff forcefully when calculating current position.
     * @param timeDiff {@code null} for reset.
     *
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    public void setTimeDiff(Long timeDiff) {
        mTimeDiff = timeDiff;
    }

    /**
     * Registers an extra {@link ControllerCallback}.
     * @param executor a callback executor
     * @param callback a ControllerCallback
     * @see #unregisterExtraCallback(ControllerCallback)
     *
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    public void registerExtraCallback(@NonNull /*@CallbackExecutor*/ Executor executor,
            @NonNull ControllerCallback callback) {
        if (executor == null) {
            throw new NullPointerException("executor shouldn't be null");
        }
        if (callback == null) {
            throw new NullPointerException("callback shouldn't be null");
        }
        boolean found = false;
        synchronized (mLock) {
            for (Pair<ControllerCallback, Executor> pair : mExtraCallbacks) {
                if (pair.first == callback) {
                    found = true;
                    break;
                }
            }
            if (!found) {
                mExtraCallbacks.add(new Pair<>(callback, executor));
            }
        }
        if (found) {
            Log.w(TAG, "registerExtraCallback: the callback already exists");
        }
    }

    /**
     * Unregisters an {@link ControllerCallback} that has been registered by
     * {@link #registerExtraCallback(Executor, ControllerCallback)}.
     * The callback passed to {@link Builder#setControllerCallback(Executor, ControllerCallback)}
     * can not be unregistered by this method.
     * @param callback a ControllerCallback
     * @see #registerExtraCallback(Executor, ControllerCallback)
     *
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    public void unregisterExtraCallback(@NonNull ControllerCallback callback) {
        if (callback == null) {
            throw new NullPointerException("callback shouldn't be null");
        }
        boolean found = false;
        synchronized (mLock) {
            for (int i = mExtraCallbacks.size() - 1; i >= 0; i--) {
                if (mExtraCallbacks.get(i).first == callback) {
                    found = true;
                    mExtraCallbacks.remove(i);
                    break;
                }
            }
        }
        if (!found) {
            Log.w(TAG, "unregisterExtraCallback: no such callback found");
        }
    }

    /** @hide */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    @RestrictTo(LIBRARY_GROUP) // TODO: LIBRARY_GROUP -> LIBRARY (b/131782509)
    @NonNull
    public List<Pair<ControllerCallback, Executor>> getExtraCallbacks() {
        List<Pair<ControllerCallback, Executor>> extraCallbacks;
        synchronized (mLock) {
            extraCallbacks = new ArrayList<>(mExtraCallbacks);
        }
        return extraCallbacks;
    }

    /**
     * Gets the cached allowed commands from {@link ControllerCallback#onAllowedCommandsChanged}.
     * If it is not connected yet, it returns {@code null}.
     *
     * @return the allowed commands
     *
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    @Nullable
    public SessionCommandGroup getAllowedCommands() {
        if (!isConnected()) {
            return null;
        }
        return getImpl().getAllowedCommands();
    }

    private static ListenableFuture<SessionResult> createDisconnectedFuture() {
        return SessionResult.createFutureWithResult(
                SessionResult.RESULT_ERROR_SESSION_DISCONNECTED);
    }

    /** @hide */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    @RestrictTo(LIBRARY_GROUP) // TODO: LIBRARY_GROUP -> LIBRARY (b/131782509)
    public void notifyControllerCallback(final ControllerCallbackRunnable callbackRunnable) {
        if (mCallback != null && mCallbackExecutor != null) {
            mCallbackExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    callbackRunnable.run(mCallback);
                }
            });
        }

        for (Pair<ControllerCallback, Executor> pair : getExtraCallbacks()) {
            final ControllerCallback callback = pair.first;
            final Executor executor = pair.second;
            if (callback == null) {
                Log.e(TAG, "notifyControllerCallback: mExtraCallbacks contains a null "
                        + "ControllerCallback! Ignoring...");
                continue;
            }
            if (executor == null) {
                Log.e(TAG, "notifyControllerCallback: mExtraCallbacks contains a null "
                        + "Executor! Ignoring...");
                continue;
            }
            executor.execute(new Runnable() {
                @Override
                public void run() {
                    callbackRunnable.run(callback);
                }
            });
        }
    }

    /** @hide */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    @RestrictTo(LIBRARY_GROUP) // TODO: LIBRARY_GROUP -> LIBRARY (b/131782509)
    public interface ControllerCallbackRunnable {
        void run(@NonNull ControllerCallback callback);
    }

    interface MediaControllerImpl extends AutoCloseable {
        @Nullable SessionToken getConnectedToken();
        boolean isConnected();
        ListenableFuture<SessionResult> play();
        ListenableFuture<SessionResult> pause();
        ListenableFuture<SessionResult> prepare();
        ListenableFuture<SessionResult> fastForward();
        ListenableFuture<SessionResult> rewind();
        ListenableFuture<SessionResult> seekTo(long pos);
        ListenableFuture<SessionResult> skipForward();
        ListenableFuture<SessionResult> skipBackward();
        ListenableFuture<SessionResult> playFromMediaId(@NonNull String mediaId,
                @Nullable Bundle extras);
        ListenableFuture<SessionResult> playFromSearch(@NonNull String query,
                @Nullable Bundle extras);
        ListenableFuture<SessionResult> playFromUri(@NonNull Uri uri, @Nullable Bundle extras);
        ListenableFuture<SessionResult> prepareFromMediaId(@NonNull String mediaId,
                @Nullable Bundle extras);
        ListenableFuture<SessionResult> prepareFromSearch(@NonNull String query,
                @Nullable Bundle extras);
        ListenableFuture<SessionResult> prepareFromUri(@NonNull Uri uri,
                @Nullable Bundle extras);
        ListenableFuture<SessionResult> setVolumeTo(int value, @VolumeFlags int flags);
        ListenableFuture<SessionResult> adjustVolume(@VolumeDirection int direction,
                @VolumeFlags int flags);
        @Nullable PendingIntent getSessionActivity();
        int getPlayerState();
        long getDuration();
        long getCurrentPosition();
        float getPlaybackSpeed();
        ListenableFuture<SessionResult> setPlaybackSpeed(float speed);
        @SessionPlayer.BuffState int getBufferingState();
        long getBufferedPosition();
        @Nullable PlaybackInfo getPlaybackInfo();
        ListenableFuture<SessionResult> setRating(@NonNull String mediaId,
                @NonNull Rating rating);
        ListenableFuture<SessionResult> sendCustomCommand(@NonNull SessionCommand command,
                @Nullable Bundle args);
        @Nullable List<MediaItem> getPlaylist();
        ListenableFuture<SessionResult> setPlaylist(@NonNull List<String> list,
                @Nullable MediaMetadata metadata);
        ListenableFuture<SessionResult> setMediaItem(@NonNull String mediaId);
        ListenableFuture<SessionResult> updatePlaylistMetadata(
                @Nullable MediaMetadata metadata);
        @Nullable MediaMetadata getPlaylistMetadata();
        ListenableFuture<SessionResult> addPlaylistItem(int index, @NonNull String mediaId);
        ListenableFuture<SessionResult> removePlaylistItem(@NonNull int index);
        ListenableFuture<SessionResult> replacePlaylistItem(int index,
                @NonNull String mediaId);
        MediaItem getCurrentMediaItem();
        int getCurrentMediaItemIndex();
        int getPreviousMediaItemIndex();
        int getNextMediaItemIndex();
        ListenableFuture<SessionResult> skipToPreviousItem();
        ListenableFuture<SessionResult> skipToNextItem();
        ListenableFuture<SessionResult> skipToPlaylistItem(@NonNull int index);
        @RepeatMode int getRepeatMode();
        ListenableFuture<SessionResult> setRepeatMode(@RepeatMode int repeatMode);
        @ShuffleMode int getShuffleMode();
        ListenableFuture<SessionResult> setShuffleMode(@ShuffleMode int shuffleMode);
        @NonNull VideoSize getVideoSize();
        ListenableFuture<SessionResult> setSurface(@Nullable Surface surface);
        @NonNull List<TrackInfo> getTrackInfo();
        ListenableFuture<SessionResult> selectTrack(TrackInfo trackInfo);
        ListenableFuture<SessionResult> deselectTrack(TrackInfo trackInfo);
        @Nullable TrackInfo getSelectedTrack(@TrackInfo.MediaTrackType int trackType);
        @Nullable SessionCommandGroup getAllowedCommands();

        // Internally used methods
        @NonNull Context getContext();
        @Nullable MediaBrowserCompat getBrowserCompat();
    }


    /**
     * Builder for {@link MediaController}.
     * <p>
     * To set the token of the session for the controller to connect to, one of the
     * {@link #setSessionToken(SessionToken)} or
     * {@link #setSessionCompatToken(MediaSessionCompat.Token)} should be called.
     * Otherwise, the {@link #build()} will throw an {@link IllegalArgumentException}.
     * <p>
     * Any incoming event from the {@link MediaSession} will be handled on the callback
     * executor.
     */
    public static final class Builder extends BuilderBase<MediaController, Builder,
            ControllerCallback> {
        public Builder(@NonNull Context context) {
            super(context);
        }

        @Override
        @NonNull
        public Builder setSessionToken(@NonNull SessionToken token) {
            return super.setSessionToken(token);
        }

        @Override
        @NonNull
        public Builder setSessionCompatToken(@NonNull MediaSessionCompat.Token compatToken) {
            return super.setSessionCompatToken(compatToken);
        }

        @Override
        @NonNull
        public Builder setControllerCallback(@NonNull Executor executor,
                @NonNull ControllerCallback callback) {
            return super.setControllerCallback(executor, callback);
        }

        /**
         * Build {@link MediaController}.
         * <p>
         * It will throw an {@link IllegalArgumentException} if both {@link SessionToken} and
         * {@link MediaSessionCompat.Token} are not set.
         *
         * @return a new controller
         */
        @Override
        @NonNull
        public MediaController build() {
            if (mToken == null && mCompatToken == null) {
                throw new IllegalArgumentException("token and compat token shouldn't be both null");
            }
            if (mToken != null) {
                return new MediaController(mContext, mToken, mConnectionHints,
                        mCallbackExecutor, mCallback);
            } else {
                return new MediaController(mContext, mCompatToken, mConnectionHints,
                        mCallbackExecutor, mCallback);
            }
        }
    }

    /**
     * Base builder class for MediaController and its subclass. Any change in this class should be
     * also applied to the subclasses {@link MediaController.Builder} and
     * {@link MediaBrowser.Builder}.
     * <p>
     * APIs here should be package private, but should have documentations for developers.
     * Otherwise, javadoc will generate documentation with the generic types such as follows.
     * <pre>U extends BuilderBase<T, U, C> setControllerCallback(Executor executor,
     * C callback)</pre>
     * <p>
     * This class is hidden to prevent from generating test stub, which fails with
     * 'unexpected bound' because it tries to auto generate stub class as follows.
     * <pre>abstract static class BuilderBase<
     *      T extends androidx.media2.MediaController,
     *      U extends androidx.media2.MediaController.BuilderBase<
     *              T, U, C extends androidx.media2.MediaController.ControllerCallback>, C></pre>
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    abstract static class BuilderBase<T extends MediaController, U extends BuilderBase<T, U, C>,
            C extends ControllerCallback> {
        final Context mContext;
        SessionToken mToken;
        MediaSessionCompat.Token mCompatToken;
        Bundle mConnectionHints;
        Executor mCallbackExecutor;
        ControllerCallback mCallback;

        /**
         * Creates a builder for {@link MediaController}.
         *
         * @param context context
         */
        BuilderBase(@NonNull Context context) {
            if (context == null) {
                throw new NullPointerException("context shouldn't be null");
            }
            mContext = context;
        }

        /**
         * Set the {@link SessionToken} for the controller to connect to.
         * <p>
         * When this method is called, the {@link MediaSessionCompat.Token} which was set by calling
         * {@link #setSessionCompatToken} is removed.
         * <p>
         * Detailed behavior of the {@link MediaController} differs according to the type of the
         * token as follows.
         * <p>
         * <ol>
         * <li>Connected to a {@link SessionToken#TYPE_SESSION} token
         * <p>
         * The controller connects to the specified session directly. It's recommended when you're
         * sure which session to control, or a you've got token directly from the session app.
         * <p>
         * This can be used only when the session for the token is running. Once the session is
         * closed, the token becomes unusable.
         * </li>
         * <li>Connected to a {@link SessionToken#TYPE_SESSION_SERVICE} or
         * {@link SessionToken#TYPE_LIBRARY_SERVICE}
         * <p>
         * The controller connects to the session provided by the
         * {@link MediaSessionService#onGetSession(ControllerInfo)}.
         * It's up to the service's decision which session would be returned for the connection.
         * Use the {@link #getConnectedSessionToken()} to know the connected session.
         * <p>
         * This can be used regardless of the session app is running or not. The controller would
         * bind to the service while connected to wake up and keep the service process running.
         * </li>
         * </ol>
         *
         * @param token token to connect to
         * @return The Builder to allow chaining
         * @see MediaSessionService#onGetSession(ControllerInfo)
         * @see #getConnectedSessionToken()
         * @see #setConnectionHints(Bundle)
         */
        @NonNull
        public U setSessionToken(@NonNull SessionToken token) {
            if (token == null) {
                throw new NullPointerException("token shouldn't be null");
            }
            mToken = token;
            mCompatToken = null;
            return (U) this;
        }

        /**
         * Set the {@link MediaSessionCompat.Token} for the controller to connect to.
         * <p>
         * When this method is called, the {@link SessionToken} which was set by calling
         * {@link #setSessionToken(SessionToken)} is removed.
         *
         * @param compatToken token to connect to
         * @return The Builder to allow chaining
         */
        @NonNull
        public U setSessionCompatToken(@NonNull MediaSessionCompat.Token compatToken) {
            if (compatToken == null) {
                throw new NullPointerException("compatToken shouldn't be null");
            }
            mCompatToken = compatToken;
            mToken = null;
            return (U) this;
        }

        /**
         * Set the connection hints for the controller.
         * <p>
         * {@code connectionHints} is a session-specific argument to send to the session when
         * connecting. The contents of this bundle may affect the connection result.
         * <p>
         * The hints specified here are only used when when connecting to the {@link MediaSession}.
         * They will be ignored when connecting to {@link MediaSessionCompat}.
         *
         * @param connectionHints a bundle which contains the connection hints
         * @return The Builder to allow chaining
         */
        @NonNull
        public U setConnectionHints(@NonNull Bundle connectionHints) {
            if (connectionHints == null) {
                throw new NullPointerException("connectionHints shouldn't be null");
            }
            mConnectionHints = new Bundle(connectionHints);
            return (U) this;
        }

        /**
         * Set callback for the controller and its executor.
         *
         * @param executor callback executor
         * @param callback controller callback.
         * @return The Builder to allow chaining
         */
        @NonNull
        public U setControllerCallback(@NonNull Executor executor, @NonNull C callback) {
            if (executor == null) {
                throw new NullPointerException("executor shouldn't be null");
            }
            if (callback == null) {
                throw new NullPointerException("callback shouldn't be null");
            }
            mCallbackExecutor = executor;
            mCallback = callback;
            return (U) this;
        }

        @NonNull
        abstract T build();
    }

    /**
     * Interface for listening to change in activeness of the {@link MediaSession}.  It's
     * active if and only if it has set a player.
     */
    public abstract static class ControllerCallback {
        /**
         * Called when the controller is successfully connected to the session. The controller
         * becomes available afterwards.
         *
         * @param controller the controller for this event
         * @param allowedCommands commands that's allowed by the session.
         */
        public void onConnected(@NonNull MediaController controller,
                @NonNull SessionCommandGroup allowedCommands) {}

        /**
         * Called when the session refuses the controller or the controller is disconnected from
         * the session. The controller becomes unavailable afterwards and the callback wouldn't
         * be called.
         * <p>
         * It will be also called after the {@link #close()}, so you can put clean up code here.
         * You don't need to call {@link #close()} after this.
         *
         * @param controller the controller for this event
         */
        public void onDisconnected(@NonNull MediaController controller) {}

        /**
         * Called when the session set the custom layout through the
         * {@link MediaSession#setCustomLayout(MediaSession.ControllerInfo, List)}.
         * <p>
         * Can be called before {@link #onConnected(MediaController, SessionCommandGroup)}
         * is called.
         * <p>
         * Default implementation returns {@link SessionResult#RESULT_ERROR_NOT_SUPPORTED}.
         *
         * @param controller the controller for this event
         * @param layout
         */
        @SessionResult.ResultCode
        public int onSetCustomLayout(
                @NonNull MediaController controller, @NonNull List<CommandButton> layout) {
            return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
        }

        /**
         * Called when the session has changed anything related with the {@link PlaybackInfo}.
         * <p>
         * Interoperability: When connected to
         * {@link android.support.v4.media.session.MediaSessionCompat}, this may be called when the
         * session changes playback info by calling
         * {@link android.support.v4.media.session.MediaSessionCompat#setPlaybackToLocal(int)} or
         * {@link android.support.v4.media.session.MediaSessionCompat#setPlaybackToRemote(
         * VolumeProviderCompat)}}. Specifically:
         * <ul>
         * <li> Prior to API 21, this will always be called whenever any of those two methods is
         *      called.
         * <li> From API 21 to 22, this is called only when the playback type is changed from local
         *      to remote (i.e. not from remote to local).
         * <li> From API 23, this is called only when the playback type is changed.
         * </ul>
         *
         * @param controller the controller for this event
         * @param info new playback info
         */
        public void onPlaybackInfoChanged(@NonNull MediaController controller,
                @NonNull PlaybackInfo info) {}

        /**
         * Called when the allowed commands are changed by session.
         *
         * @param controller the controller for this event
         * @param commands newly allowed commands
         */
        public void onAllowedCommandsChanged(@NonNull MediaController controller,
                @NonNull SessionCommandGroup commands) {}

        /**
         * Called when the session sent a custom command. Returns a {@link SessionResult} for
         * session to get notification back. If the {@code null} is returned,
         * {@link SessionResult#RESULT_ERROR_UNKNOWN} will be returned.
         * <p>
         * Default implementation returns {@link SessionResult#RESULT_ERROR_NOT_SUPPORTED}.
         *
         * @param controller the controller for this event
         * @param command
         * @param args
         * @return result of handling custom command
         */
        @NonNull
        public SessionResult onCustomCommand(@NonNull MediaController controller,
                @NonNull SessionCommand command, @Nullable Bundle args) {
            return new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED);
        }

        /**
         * Called when the player state is changed.
         *
         * @param controller the controller for this event
         * @param state the new player state
         */
        public void onPlayerStateChanged(@NonNull MediaController controller,
                @SessionPlayer.PlayerState int state) {}

        /**
         * Called when playback speed is changed.
         *
         * @param controller the controller for this event
         * @param speed speed
         */
        public void onPlaybackSpeedChanged(@NonNull MediaController controller,
                float speed) {}

        /**
         * Called to report buffering events for a media item.
         * <p>
         * Use {@link #getBufferedPosition()} for current buffering position.
         *
         * @param controller the controller for this event
         * @param item the media item for which buffering is happening.
         * @param state the new buffering state.
         */
        public void onBufferingStateChanged(@NonNull MediaController controller,
                @NonNull MediaItem item, @SessionPlayer.BuffState int state) {}

        /**
         * Called to indicate that seeking is completed.
         *
         * @param controller the controller for this event.
         * @param position the previous seeking request.
         */
        public void onSeekCompleted(@NonNull MediaController controller, long position) {}

        /**
         * Called when the player's current item is changed. It's also called after
         * {@link #setPlaylist} or {@link #setMediaItem}.
         * <p>
         * When it's called, you should invalidate previous playback information and wait for later
         * callbacks. Also, current, previous, and next media item indices may need to be updated.
         *
         * @param controller the controller for this event
         * @param item new current media item
         * @see #getPlaylist()
         * @see #getPlaylistMetadata()
         */
        public void onCurrentMediaItemChanged(@NonNull MediaController controller,
                @Nullable MediaItem item) {}

        /**
         * Called when a playlist is changed. It's also called after {@link #setPlaylist} or
         * {@link #setMediaItem}.
         * <p>
         * When it's called, current, previous, and next media item indices may need to be updated.
         *
         * @param controller the controller for this event
         * @param list new playlist
         * @param metadata new metadata
         * @see #getPlaylist()
         * @see #getPlaylistMetadata()
         */
        public void onPlaylistChanged(@NonNull MediaController controller,
                @Nullable List<MediaItem> list, @Nullable MediaMetadata metadata) {}

        /**
         * Called when a playlist metadata is changed.
         *
         * @param controller the controller for this event
         * @param metadata new metadata
         */
        public void onPlaylistMetadataChanged(@NonNull MediaController controller,
                @Nullable MediaMetadata metadata) {}

        /**
         * Called when the shuffle mode is changed.
         *
         * @param controller the controller for this event
         * @param shuffleMode repeat mode
         * @see SessionPlayer#SHUFFLE_MODE_NONE
         * @see SessionPlayer#SHUFFLE_MODE_ALL
         * @see SessionPlayer#SHUFFLE_MODE_GROUP
         */
        public void onShuffleModeChanged(@NonNull MediaController controller,
                @SessionPlayer.ShuffleMode int shuffleMode) {}

        /**
         * Called when the repeat mode is changed.
         *
         * @param controller the controller for this event
         * @param repeatMode repeat mode
         * @see SessionPlayer#REPEAT_MODE_NONE
         * @see SessionPlayer#REPEAT_MODE_ONE
         * @see SessionPlayer#REPEAT_MODE_ALL
         * @see SessionPlayer#REPEAT_MODE_GROUP
         */
        public void onRepeatModeChanged(@NonNull MediaController controller,
                @SessionPlayer.RepeatMode int repeatMode) {}

        /**
         * Called when the playback is completed.
         *
         * @param controller the controller for this event
         */
        public void onPlaybackCompleted(@NonNull MediaController controller) {}

        /**
         * Called when video size is changed.
         *
         * @param controller the controller for this event
         * @param item the media item for which the video size changed
         * @param videoSize the size of video
         *
         * @hide
         */
        @RestrictTo(LIBRARY_GROUP)
        public void onVideoSizeChanged(@NonNull MediaController controller, @NonNull MediaItem item,
                @NonNull VideoSize videoSize) {}

        /**
         * Called when the tracks are first retrieved after media is prepared or when new tracks are
         * found during playback.
         * <p>
         * When it's called, you should invalidate previous track information and use the new
         * tracks to call {@link #selectTrack(TrackInfo)} or
         * {@link #deselectTrack(TrackInfo)}.
         * <p>
         * The types of tracks supported may vary based on player implementation.
         *
         * @see TrackInfo#MEDIA_TRACK_TYPE_VIDEO
         * @see TrackInfo#MEDIA_TRACK_TYPE_AUDIO
         * @see TrackInfo#MEDIA_TRACK_TYPE_SUBTITLE
         * @see TrackInfo#MEDIA_TRACK_TYPE_METADATA
         *
         * @param controller the controller for this event
         * @param trackInfos the list of track infos
         * @hide
         */
        @RestrictTo(LIBRARY_GROUP)
        public void onTrackInfoChanged(@NonNull MediaController controller,
                @NonNull List<TrackInfo> trackInfos) {}

        /**
         * Called when a track is selected.
         * <p>
         * The types of tracks supported may vary based on player implementation, but generally
         * one track will be selected for each track type.
         *
         * @see TrackInfo#MEDIA_TRACK_TYPE_VIDEO
         * @see TrackInfo#MEDIA_TRACK_TYPE_AUDIO
         * @see TrackInfo#MEDIA_TRACK_TYPE_SUBTITLE
         * @see TrackInfo#MEDIA_TRACK_TYPE_METADATA
         *
         * @param controller the controller for this event
         * @param trackInfo the selected track
         * @hide
         */
        @RestrictTo(LIBRARY_GROUP)
        public void onTrackSelected(@NonNull MediaController controller,
                @NonNull TrackInfo trackInfo) {}

        /**
         * Called when a track is deselected.
         * <p>
         * The types of tracks supported may vary based on player implementation, but generally
         * a track should already be selected in order to be deselected and audio and video tracks
         * should not be deselected.
         *
         * @see TrackInfo#MEDIA_TRACK_TYPE_VIDEO
         * @see TrackInfo#MEDIA_TRACK_TYPE_AUDIO
         * @see TrackInfo#MEDIA_TRACK_TYPE_SUBTITLE
         * @see TrackInfo#MEDIA_TRACK_TYPE_METADATA
         *
         * @param controller the controller for this event
         * @param trackInfo the deselected track
         * @hide
         */
        @RestrictTo(LIBRARY_GROUP)
        public void onTrackDeselected(@NonNull MediaController controller,
                @NonNull TrackInfo trackInfo) {}

        /**
         * Called when the subtitle track has new subtitle data available.
         * @param controller the controller for this event
         * @param item the MediaItem of this media item
         * @param track the track that has the subtitle data
         * @param data the subtitle data
         *
         * @hide
         */
        @RestrictTo(LIBRARY_GROUP)
        public void onSubtitleData(@NonNull MediaController controller, @NonNull MediaItem item,
                @NonNull TrackInfo track, @NonNull SubtitleData data) {}
    }

    /**
     * Holds information about the the way volume is handled for this session.
     */
    // The same as MediaController.PlaybackInfo
    @VersionedParcelize
    public static final class PlaybackInfo implements VersionedParcelable {
        @ParcelField(1)
        int mPlaybackType;
        @ParcelField(2)
        int mControlType;
        @ParcelField(3)
        int mMaxVolume;
        @ParcelField(4)
        int mCurrentVolume;
        @ParcelField(5)
        AudioAttributesCompat mAudioAttrsCompat;

        /**
         * The session uses local playback.
         */
        public static final int PLAYBACK_TYPE_LOCAL = 1;
        /**
         * The session uses remote playback.
         */
        public static final int PLAYBACK_TYPE_REMOTE = 2;

        /**
         * Used for VersionedParcelable
         */
        PlaybackInfo() {
        }

        PlaybackInfo(int playbackType, AudioAttributesCompat attrs, int controlType, int max,
                int current) {
            mPlaybackType = playbackType;
            mAudioAttrsCompat = attrs;
            mControlType = controlType;
            mMaxVolume = max;
            mCurrentVolume = current;
        }

        /**
         * Get the type of playback which affects volume handling. One of:
         * <ul>
         * <li>{@link #PLAYBACK_TYPE_LOCAL}</li>
         * <li>{@link #PLAYBACK_TYPE_REMOTE}</li>
         * </ul>
         *
         * @return The type of playback this session is using
         */
        public int getPlaybackType() {
            return mPlaybackType;
        }

        /**
         * Get the audio attributes for this session. The attributes will affect
         * volume handling for the session. When the volume type is
         * {@link #PLAYBACK_TYPE_REMOTE} these may be ignored by the
         * remote volume handler.
         *
         * @return The attributes for this session
         */
        @Nullable
        public AudioAttributesCompat getAudioAttributes() {
            return mAudioAttrsCompat;
        }

        /**
         * Get the type of volume control that can be used. One of:
         * <ul>
         * <li>{@link VolumeProviderCompat#VOLUME_CONTROL_ABSOLUTE}</li>
         * <li>{@link VolumeProviderCompat#VOLUME_CONTROL_RELATIVE}</li>
         * <li>{@link VolumeProviderCompat#VOLUME_CONTROL_FIXED}</li>
         * </ul>
         *
         * @return The type of volume control that may be used with this session
         */
        public int getControlType() {
            return mControlType;
        }

        /**
         * Get the maximum volume that may be set for this session.
         * <p>
         * This is only meaningful when the playback type is {@link #PLAYBACK_TYPE_REMOTE}.
         *
         * @return The maximum allowed volume where this session is playing
         */
        public int getMaxVolume() {
            return mMaxVolume;
        }

        /**
         * Get the current volume for this session.
         * <p>
         * This is only meaningful when the playback type is {@link #PLAYBACK_TYPE_REMOTE}.
         *
         * @return The current volume where this session is playing
         */
        public int getCurrentVolume() {
            return mCurrentVolume;
        }

        @Override
        public int hashCode() {
            return ObjectsCompat.hash(
                    mPlaybackType, mControlType, mMaxVolume, mCurrentVolume, mAudioAttrsCompat);
        }

        @Override
        public boolean equals(@Nullable Object obj) {
            if (!(obj instanceof PlaybackInfo)) {
                return false;
            }
            PlaybackInfo other = (PlaybackInfo) obj;
            return mPlaybackType == other.mPlaybackType
                    && mControlType == other.mControlType
                    && mMaxVolume == other.mMaxVolume
                    && mCurrentVolume == other.mCurrentVolume
                    && ObjectsCompat.equals(mAudioAttrsCompat, other.mAudioAttrsCompat);
        }

        static PlaybackInfo createPlaybackInfo(int playbackType, AudioAttributesCompat attrs,
                int controlType, int max, int current) {
            return new PlaybackInfo(playbackType, attrs, controlType, max, current);
        }
    }
}