public class

MediaController

extends java.lang.Object

implements Player

 java.lang.Object

↳androidx.media3.session.MediaController

Subclasses:

MediaBrowser

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-session', version: '1.0.0-alpha03'

  • groupId: androidx.media3
  • artifactId: media3-session
  • version: 1.0.0-alpha03

Artifact androidx.media3:media3-session:1.0.0-alpha03 it located at Google repository (https://maven.google.com/)

Overview

A controller that interacts with a MediaSession, a MediaSessionService hosting a MediaSession, or a MediaLibraryService hosting a MediaLibraryService.MediaLibrarySession. The MediaSession typically resides in a remote process like another app but may be in the same process as this controller. It implements Player and the player commands are sent to the underlying Player of the connected MediaSession. It also has session-specific commands that can be handled by MediaSession.SessionCallback.

Topics covered here:

  1. Controller Lifecycle
  2. Threading Model
  3. Package Visibility Filter

Controller Lifecycle

When a controller is created with the SessionToken for a MediaSession (i.e. session token type is SessionToken.TYPE_SESSION), the controller will connect to the specific session.

When a controller is created with the SessionToken for a MediaSessionService (i.e. session token type is SessionToken.TYPE_SESSION_SERVICE or SessionToken.TYPE_LIBRARY_SERVICE), the controller binds to the service for connecting to a MediaSession in it. MediaSessionService will provide a session to connect.

When you're done, use MediaController.releaseFuture(Future) or MediaController.release() to clean up resources. This also helps session service to be destroyed when there's no controller associated with it.

Threading Model

Methods of this class should be called from the application thread associated with the application looper. Otherwise, java.lang.IllegalStateException will be thrown. Also, the methods of and MediaController.Listener will be called from the application thread.

Package Visibility Filter

The app targeting API level 30 or higher must include a element in their manifest to connect to a service component of another app like MediaSessionService, MediaLibraryService, or MediaBrowserServiceCompat). See the following example and this guide for more information.

 
 
   
 
 
   
 
 
   
 
 
 
 

Summary

Methods
public voidaddListener(Player.Listener listener)

public voidaddMediaItem(int index, MediaItem mediaItem)

public voidaddMediaItem(MediaItem mediaItem)

public voidaddMediaItems(int index, java.util.List<MediaItem> mediaItems)

public voidaddMediaItems(java.util.List<MediaItem> mediaItems)

public booleancanAdvertiseSession()

public voidclearMediaItems()

public voidclearVideoSurface()

public voidclearVideoSurface(Surface surface)

public voidclearVideoSurfaceHolder(SurfaceHolder surfaceHolder)

public voidclearVideoSurfaceView(SurfaceView surfaceView)

public voidclearVideoTextureView(TextureView textureView)

public voiddecreaseDeviceVolume()

public LoopergetApplicationLooper()

public AudioAttributesgetAudioAttributes()

public Player.CommandsgetAvailableCommands()

public SessionCommandsgetAvailableSessionCommands()

Returns the current available session commands from MediaController.Listener.onAvailableSessionCommandsChanged(MediaController, SessionCommands), or SessionCommands.EMPTY if it is not connected.

public intgetBufferedPercentage()

public longgetBufferedPosition()

public SessionTokengetConnectedToken()

Returns the SessionToken of the connected session, or null if it is not connected.

public longgetContentBufferedPosition()

public longgetContentDuration()

public longgetContentPosition()

public intgetCurrentAdGroupIndex()

public intgetCurrentAdIndexInAdGroup()

public java.util.List<Cue>getCurrentCues()

public longgetCurrentLiveOffset()

public java.lang.ObjectgetCurrentManifest()

Returns null.

public MediaItemgetCurrentMediaItem()

public intgetCurrentMediaItemIndex()

public intgetCurrentPeriodIndex()

public longgetCurrentPosition()

public TimelinegetCurrentTimeline()

public TrackGroupArraygetCurrentTrackGroups()

Returns TrackGroupArray.EMPTY.

public TrackSelectionArraygetCurrentTrackSelections()

Returns an empty TrackSelectionArray.

public TracksInfogetCurrentTracksInfo()

public intgetCurrentWindowIndex()

public DeviceInfogetDeviceInfo()

public intgetDeviceVolume()

public longgetDuration()

public longgetMaxSeekToPreviousPosition()

public MediaItemgetMediaItemAt(int index)

public intgetMediaItemCount()

public MediaMetadatagetMediaMetadata()

public intgetNextMediaItemIndex()

public intgetNextWindowIndex()

public PlaybackParametersgetPlaybackParameters()

public intgetPlaybackState()

public intgetPlaybackSuppressionReason()

public PlaybackExceptiongetPlayerError()

public MediaMetadatagetPlaylistMetadata()

public booleangetPlayWhenReady()

public intgetPreviousMediaItemIndex()

public intgetPreviousWindowIndex()

public intgetRepeatMode()

public longgetSeekBackIncrement()

public longgetSeekForwardIncrement()

public PendingIntentgetSessionActivity()

Returns an intent for launching UI associated with the session if exists, or null.

public booleangetShuffleModeEnabled()

public longgetTotalBufferedDuration()

public TrackSelectionParametersgetTrackSelectionParameters()

public VideoSizegetVideoSize()

public floatgetVolume()

public booleanhasNext()

public booleanhasNextMediaItem()

public booleanhasNextWindow()

public booleanhasPrevious()

public booleanhasPreviousMediaItem()

public booleanhasPreviousWindow()

public voidincreaseDeviceVolume()

public booleanisCommandAvailable(int command)

public booleanisConnected()

Returns whether this controller is connected to a MediaSession or not.

public booleanisCurrentMediaItemDynamic()

public booleanisCurrentMediaItemLive()

public booleanisCurrentMediaItemSeekable()

public booleanisCurrentWindowDynamic()

public booleanisCurrentWindowLive()

public booleanisCurrentWindowSeekable()

public booleanisDeviceMuted()

public booleanisLoading()

public booleanisPlaying()

public booleanisPlayingAd()

public booleanisSessionCommandAvailable(int sessionCommandCode)

Returns whether the SessionCommand.CommandCode is available.

public booleanisSessionCommandAvailable(SessionCommand sessionCommand)

Returns whether the SessionCommand is available.

public voidmoveMediaItem(int currentIndex, int newIndex)

public voidmoveMediaItems(int fromIndex, int toIndex, int newIndex)

public voidnext()

public voidpause()

public voidplay()

public voidprepare()

public voidprevious()

public voidrelease()

Releases the connection between MediaController and MediaSession.

public static voidreleaseFuture(java.util.concurrent.Future<MediaController> controllerFuture)

Releases the future controller returned by MediaController.Builder.buildAsync().

public voidremoveListener(Player.Listener listener)

public voidremoveMediaItem(int index)

public voidremoveMediaItems(int fromIndex, int toIndex)

public voidseekBack()

public voidseekForward()

public voidseekTo(int mediaItemIndex, long positionMs)

public voidseekTo(long positionMs)

public voidseekToDefaultPosition()

public voidseekToDefaultPosition(int mediaItemIndex)

public voidseekToNext()

public voidseekToNextMediaItem()

public voidseekToNextWindow()

public voidseekToPrevious()

public voidseekToPreviousMediaItem()

public voidseekToPreviousWindow()

public <any>sendCustomCommand(SessionCommand command, Bundle args)

Sends a custom command to the session.

public voidsetDeviceMuted(boolean muted)

public voidsetDeviceVolume(int volume)

public voidsetMediaItem(MediaItem mediaItem)

public voidsetMediaItem(MediaItem mediaItem, boolean resetPosition)

public voidsetMediaItem(MediaItem mediaItem, long startPositionMs)

public voidsetMediaItems(java.util.List<MediaItem> mediaItems)

public voidsetMediaItems(java.util.List<MediaItem> mediaItems, boolean resetPosition)

public voidsetMediaItems(java.util.List<MediaItem> mediaItems, int startIndex, long startPositionMs)

public <any>setMediaUri(Uri uri, Bundle extras)

Requests that the connected MediaSession sets a specific for playback.

public voidsetPlaybackParameters(PlaybackParameters playbackParameters)

public voidsetPlaybackSpeed(float speed)

public voidsetPlaylistMetadata(MediaMetadata playlistMetadata)

public voidsetPlayWhenReady(boolean playWhenReady)

public <any>setRating(Rating rating)

Requests that the connected MediaSession rates the current media item.

public <any>setRating(java.lang.String mediaId, Rating rating)

Requests that the connected MediaSession rates the media.

public voidsetRepeatMode(int repeatMode)

public voidsetShuffleModeEnabled(boolean shuffleModeEnabled)

public voidsetTrackSelectionParameters(TrackSelectionParameters parameters)

public voidsetVideoSurface(Surface surface)

public voidsetVideoSurfaceHolder(SurfaceHolder surfaceHolder)

public voidsetVideoSurfaceView(SurfaceView surfaceView)

public voidsetVideoTextureView(TextureView textureView)

public voidsetVolume(float volume)

public voidstop()

public voidstop(boolean reset)

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

Methods

public void stop()

public void stop(boolean reset)

public void release()

Releases the connection between MediaController and MediaSession. This method must be called when the controller is no longer required. The controller must not be used after calling this method.

This method does not call Player.release() of the underlying player in the session.

public static void releaseFuture(java.util.concurrent.Future<MediaController> controllerFuture)

Releases the future controller returned by MediaController.Builder.buildAsync(). It makes sure that the controller is released by canceling the future if the future is not yet done.

public SessionToken getConnectedToken()

Returns the SessionToken of the connected session, or null if it is not connected.

This may differ from the SessionToken from the constructor. For example, if the controller is created with the token for MediaSessionService, this will return a token for the MediaSession in the service.

public boolean isConnected()

Returns whether this controller is connected to a MediaSession or not.

public void play()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, then this will be grouped together with previously called MediaController.setMediaUri(Uri, Bundle). See MediaController.setMediaUri(Uri, Bundle) for details.

public void pause()

public void prepare()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, then this will be grouped together with previously called MediaController.setMediaUri(Uri, Bundle). See MediaController.setMediaUri(Uri, Bundle) for details.

public void seekToDefaultPosition()

public void seekToDefaultPosition(int mediaItemIndex)

public void seekTo(long positionMs)

public void seekTo(int mediaItemIndex, long positionMs)

public long getSeekBackIncrement()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it returns {code 0}.

public void seekBack()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it calls .

public long getSeekForwardIncrement()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it returns {code 0}.

public void seekForward()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it calls .

public PendingIntent getSessionActivity()

Returns an intent for launching UI associated with the session if exists, or null.

public PlaybackException getPlayerError()

public void setPlayWhenReady(boolean playWhenReady)

public boolean getPlayWhenReady()

public int getPlaybackSuppressionReason()

public int getPlaybackState()

public boolean isPlaying()

public boolean isLoading()

public long getDuration()

public long getCurrentPosition()

public long getBufferedPosition()

public int getBufferedPercentage()

public long getTotalBufferedDuration()

public long getCurrentLiveOffset()

public long getContentDuration()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it's the same as MediaController.getDuration() to match the behavior with MediaController.getContentPosition() and MediaController.getContentBufferedPosition().

public long getContentPosition()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it's the same as MediaController.getCurrentPosition() because content position isn't available.

public long getContentBufferedPosition()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it's the same as MediaController.getBufferedPosition() because content buffered position isn't available.

public boolean isPlayingAd()

public int getCurrentAdGroupIndex()

public int getCurrentAdIndexInAdGroup()

public void setPlaybackParameters(PlaybackParameters playbackParameters)

public void setPlaybackSpeed(float speed)

public PlaybackParameters getPlaybackParameters()

public AudioAttributes getAudioAttributes()

public <any> setRating(java.lang.String mediaId, Rating rating)

Requests that the connected MediaSession rates 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 MediaMetadata.userRating.

If the user rating was null, the media item does not accept setting user rating.

Parameters:

mediaId: The non-empty MediaItem.mediaId.
rating: The rating to set.

Returns:

A of SessionResult representing the pending completion.

public <any> setRating(Rating rating)

Requests that the connected MediaSession rates the current media item. 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 MediaMetadata.userRating.

If the user rating was null, the media item does not accept setting user rating.

Parameters:

rating: The rating to set.

Returns:

A of SessionResult representing the pending completion.

public <any> sendCustomCommand(SessionCommand command, Bundle args)

Sends a custom command to the session.

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, SessionResult.resultCode will return the custom result code from the android.os.ResultReceiver#onReceiveResult(int, Bundle) instead of the standard result codes defined in the SessionResult.

A command is not accepted if it is not a custom command.

Parameters:

command: The custom command.
args: The additional arguments. May be empty.

Returns:

A of SessionResult representing the pending completion.

public java.lang.Object getCurrentManifest()

Returns null.

public Timeline getCurrentTimeline()

Caveat: Some methods of the Timeline such as Timeline.getPeriodByUid(Object, Timeline.Period), Timeline.getIndexOfPeriod(Object), and Timeline.getUidOfPeriod(int) will throw java.lang.UnsupportedOperationException because of the limitation of restoring the instance sent from session as described in Timeline.CREATOR.

public void setMediaItem(MediaItem mediaItem)

public void setMediaItem(MediaItem mediaItem, long startPositionMs)

public void setMediaItem(MediaItem mediaItem, boolean resetPosition)

public void setMediaItems(java.util.List<MediaItem> mediaItems)

public void setMediaItems(java.util.List<MediaItem> mediaItems, boolean resetPosition)

public void setMediaItems(java.util.List<MediaItem> mediaItems, int startIndex, long startPositionMs)

public <any> setMediaUri(Uri uri, Bundle extras)

Requests that the connected MediaSession sets a specific for playback. Use this, or MediaController.setMediaItems(List) to specify which item(s) to play.

This can be called multiple times in any states. This would override previous call of this, or MediaController.setMediaItems(List).

The and/or would be called when it's completed.

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, this call will be grouped together with later MediaController.prepare() or MediaController.play(), depending on the uri pattern as follows:

Uri patterns and following API calls for MediaControllerCompat methods
Uri patternsFollowing API callsMethod
androidx://media3-session/setMediaUri?uri=[uri] MediaController.prepare()
MediaController.play()
androidx://media3-session/setMediaUri?id=[mediaId] MediaController.prepare()
MediaController.play()
androidx://media3-session/setMediaUri?query=[query] MediaController.prepare()
MediaController.play()
Does not match with any pattern above MediaController.prepare()
MediaController.play()

Returned will return SessionResult.RESULT_SUCCESS when it's handled together with MediaController.prepare() or MediaController.play(). If this API is called multiple times without prepare or play, then SessionResult.RESULT_INFO_SKIPPED will be returned for previous calls.

Parameters:

uri: The uri of the item(s) to play.
extras: A to send extra information. May be empty.

Returns:

A of SessionResult representing the pending completion.

See also: MediaConstants.MEDIA_URI_AUTHORITY, MediaConstants.MEDIA_URI_PATH_PREPARE_FROM_MEDIA_ID, MediaConstants.MEDIA_URI_PATH_PLAY_FROM_MEDIA_ID, MediaConstants.MEDIA_URI_PATH_PREPARE_FROM_SEARCH, MediaConstants.MEDIA_URI_PATH_PLAY_FROM_SEARCH, MediaConstants.MEDIA_URI_PATH_SET_MEDIA_URI, MediaConstants.MEDIA_URI_QUERY_ID, MediaConstants.MEDIA_URI_QUERY_QUERY, MediaConstants.MEDIA_URI_QUERY_URI

public void setPlaylistMetadata(MediaMetadata playlistMetadata)

public MediaMetadata getPlaylistMetadata()

public void addMediaItem(MediaItem mediaItem)

public void addMediaItem(int index, MediaItem mediaItem)

public void addMediaItems(java.util.List<MediaItem> mediaItems)

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, this doesn't atomically add items.

public void addMediaItems(int index, java.util.List<MediaItem> mediaItems)

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, this doesn't atomically add items.

public void removeMediaItem(int index)

public void removeMediaItems(int fromIndex, int toIndex)

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, this doesn't atomically remove items.

public void clearMediaItems()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, this doesn't atomically clear items.

public void moveMediaItem(int currentIndex, int newIndex)

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, this doesn't atomically move items.

public void moveMediaItems(int fromIndex, int toIndex, int newIndex)

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, this doesn't atomically move items.

public boolean isCurrentWindowDynamic()

public boolean isCurrentMediaItemDynamic()

public boolean isCurrentWindowLive()

public boolean isCurrentMediaItemLive()

public boolean isCurrentWindowSeekable()

public boolean isCurrentMediaItemSeekable()

public boolean canAdvertiseSession()

The MediaController returns false.

public MediaItem getCurrentMediaItem()

public int getMediaItemCount()

public MediaItem getMediaItemAt(int index)

public int getCurrentPeriodIndex()

public int getCurrentWindowIndex()

public int getCurrentMediaItemIndex()

public int getPreviousWindowIndex()

public int getPreviousMediaItemIndex()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, this will always return C.INDEX_UNSET even when MediaController.hasPreviousMediaItem() is true.

public int getNextWindowIndex()

public int getNextMediaItemIndex()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, this will always return C.INDEX_UNSET even when MediaController.hasNextMediaItem() is true.

public boolean hasPrevious()

public boolean hasNext()

public boolean hasPreviousWindow()

public boolean hasNextWindow()

public boolean hasPreviousMediaItem()

public boolean hasNextMediaItem()

public void previous()

public void next()

public void seekToPreviousWindow()

public void seekToPreviousMediaItem()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it's the same as MediaController.seekToPrevious().

public void seekToNextWindow()

public void seekToNextMediaItem()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it's the same as MediaController.seekToNext().

public void seekToPrevious()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it won't update the current media item index immediately because the previous media item index is unknown.

public long getMaxSeekToPreviousPosition()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it always returns 0.

public void seekToNext()

Interoperability: When connected to android.support.v4.media.session.MediaSessionCompat, it won't update the current media item index immediately because the previous media item index is unknown.

public int getRepeatMode()

public void setRepeatMode(int repeatMode)

public boolean getShuffleModeEnabled()

public void setShuffleModeEnabled(boolean shuffleModeEnabled)

public VideoSize getVideoSize()

public void clearVideoSurface()

public void clearVideoSurface(Surface surface)

public void setVideoSurface(Surface surface)

public void setVideoSurfaceHolder(SurfaceHolder surfaceHolder)

public void clearVideoSurfaceHolder(SurfaceHolder surfaceHolder)

public void setVideoSurfaceView(SurfaceView surfaceView)

public void clearVideoSurfaceView(SurfaceView surfaceView)

public void setVideoTextureView(TextureView textureView)

public void clearVideoTextureView(TextureView textureView)

public java.util.List<Cue> getCurrentCues()

public float getVolume()

public void setVolume(float volume)

public DeviceInfo getDeviceInfo()

public int getDeviceVolume()

public boolean isDeviceMuted()

public void setDeviceVolume(int volume)

public void increaseDeviceVolume()

public void decreaseDeviceVolume()

public void setDeviceMuted(boolean muted)

public MediaMetadata getMediaMetadata()

public TrackGroupArray getCurrentTrackGroups()

Returns TrackGroupArray.EMPTY.

public TrackSelectionArray getCurrentTrackSelections()

Returns an empty TrackSelectionArray.

public TracksInfo getCurrentTracksInfo()

public TrackSelectionParameters getTrackSelectionParameters()

public void setTrackSelectionParameters(TrackSelectionParameters parameters)

public Looper getApplicationLooper()

public void addListener(Player.Listener listener)

public void removeListener(Player.Listener listener)

public boolean isCommandAvailable(int command)

public Player.Commands getAvailableCommands()

public boolean isSessionCommandAvailable(int sessionCommandCode)

Returns whether the SessionCommand.CommandCode is available. The sessionCommandCode must not be SessionCommand.COMMAND_CODE_CUSTOM. Use MediaController.isSessionCommandAvailable(SessionCommand) for custom commands.

public boolean isSessionCommandAvailable(SessionCommand sessionCommand)

Returns whether the SessionCommand is available.

public SessionCommands getAvailableSessionCommands()

Returns the current available session commands from MediaController.Listener.onAvailableSessionCommandsChanged(MediaController, SessionCommands), or SessionCommands.EMPTY if it is not connected.

Returns:

The available session commands.

Source

/*
 * 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.media3.session;

import static androidx.annotation.VisibleForTesting.NONE;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotEmpty;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Util.postOrRun;

import android.app.PendingIntent;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.TextureView;
import androidx.annotation.FloatRange;
import androidx.annotation.IntRange;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C;
import androidx.media3.common.DeviceInfo;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.Rating;
import androidx.media3.common.Timeline;
import androidx.media3.common.TrackGroupArray;
import androidx.media3.common.TrackSelectionArray;
import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.TracksInfo;
import androidx.media3.common.VideoSize;
import androidx.media3.common.text.Cue;
import androidx.media3.common.util.Consumer;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.Future;
import org.checkerframework.checker.initialization.qual.Initialized;

/**
 * A controller that interacts with a {@link MediaSession}, a {@link MediaSessionService} hosting a
 * {@link MediaSession}, or a {@link MediaLibraryService} hosting a {@link
 * MediaLibraryService.MediaLibrarySession}. The {@link MediaSession} typically resides in a remote
 * process like another app but may be in the same process as this controller. It implements {@link
 * Player} and the player commands are sent to the underlying {@link Player} of the connected {@link
 * MediaSession}. It also has session-specific commands that can be handled by {@link
 * MediaSession.SessionCallback}.
 *
 * <p>Topics covered here:
 *
 * <ol>
 *   <li><a href="#ControllerLifeCycle">Controller Lifecycle</a>
 *   <li><a href="#ThreadingModel">Threading Model</a>
 *   <li><a href="#PackageVisibilityFilter">Package Visibility Filter</a>
 * </ol>
 *
 * <h2 id="ControllerLifeCycle">Controller Lifecycle</h2>
 *
 * <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 you're done, use {@link #releaseFuture(Future)} or {@link #release()} to clean up
 * resources. This also helps session service to be destroyed when there's no controller associated
 * with it.
 *
 * <h2 id="ThreadingModel">Threading Model</h2>
 *
 * <p>Methods of this class should be called from the application thread associated with the {@link
 * #getApplicationLooper() application looper}. Otherwise, {@link IllegalStateException} will be
 * thrown. Also, the methods of {@link Player.Listener} and {@link Listener} will be called from the
 * application thread.
 *
 * <h2 id="PackageVisibilityFilter">Package Visibility Filter</h2>
 *
 * <p>The app targeting API level 30 or higher must include a {@code <queries>} element in their
 * manifest to connect to a service component of another app like {@link MediaSessionService},
 * {@link MediaLibraryService}, or {@link androidx.media.MediaBrowserServiceCompat}). See the
 * following example and <a href="//developer.android.com/training/package-visibility">this
 * guide</a> for more information.
 *
 * <pre>{@code
 * <!-- As intent actions -->
 * <intent>
 *   <action android:name="androidx.media3.session.MediaSessionService" />
 * </intent>
 * <intent>
 *   <action android:name="androidx.media3.session.MediaLibraryService" />
 * </intent>
 * <intent>
 *   <action android:name="android.media.browse.MediaBrowserService" />
 * </intent>
 * <!-- Or, as a package name -->
 * <package android:name="package_name_of_the_other_app" />
 * }</pre>
 */
public class MediaController implements Player {

  private static final String TAG = "MediaController";

  private static final String WRONG_THREAD_ERROR_MESSAGE =
      "MediaController method is called from a wrong thread."
          + " See javadoc of MediaController for details.";

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

    private final Context context;
    private final SessionToken token;
    private Bundle connectionHints;
    private Listener listener;
    private Looper applicationLooper;

    /**
     * Creates a builder for {@link MediaController}.
     *
     * <p>The detailed behavior of the {@link MediaController} differs depending on the type of the
     * token as follows.
     *
     * <ol>
     *   <li>{@link SessionToken#TYPE_SESSION}: The controller connects to the specified session
     *       directly. It's recommended when you're sure which session to control, or you've got a
     *       token directly from the session app. This can be used only when the session for the
     *       token is running. Once the session is closed, the token becomes unusable.
     *   <li>{@link SessionToken#TYPE_SESSION_SERVICE} or {@link SessionToken#TYPE_LIBRARY_SERVICE}:
     *       The controller connects to the session provided by the {@link
     *       MediaSessionService#onGetSession(MediaSession.ControllerInfo)} or {@link
     *       MediaLibraryService#onGetSession(MediaSession.ControllerInfo)}. It's up to the service
     *       to decide which session should be returned for the connection. Use the {@link
     *       #getConnectedToken()} to know the connected session. This can be used regardless of
     *       whether the session app is running or not. The controller will bind to the service as
     *       long as it's connected to wake up and keep the service process running.
     * </ol>
     *
     * @param context The context.
     * @param token The token to connect to.
     */
    public Builder(Context context, SessionToken token) {
      this.context = checkNotNull(context);
      this.token = checkNotNull(token);
      connectionHints = Bundle.EMPTY;
      listener = new Listener() {};
      applicationLooper = Util.getCurrentOrMainLooper();
    }

    /**
     * Sets connection hints for the controller.
     *
     * <p>The hints are session-specific arguments sent to the session when connecting. The contents
     * of this bundle may affect the connection result.
     *
     * <p>The hints are only used when connecting to the {@link MediaSession}. They will be ignored
     * when connecting to {@link MediaSessionCompat}.
     *
     * @param connectionHints A bundle containing the connection hints.
     * @return The builder to allow chaining.
     */
    public Builder setConnectionHints(Bundle connectionHints) {
      this.connectionHints = new Bundle(checkNotNull(connectionHints));
      return this;
    }

    /**
     * Sets a listener for the controller.
     *
     * @param listener The listener.
     * @return The builder to allow chaining.
     */
    public Builder setListener(Listener listener) {
      this.listener = checkNotNull(listener);
      return this;
    }

    /**
     * Sets a {@link Looper} that must be used for all calls to the {@link Player} methods and that
     * is used to call {@link Player.Listener} methods on. The {@link Looper#myLooper()} current
     * looper} at that time this builder is created will be used if not specified. The {@link
     * Looper#getMainLooper() main looper} will be used if the current looper doesn't exist.
     *
     * @param looper The looper.
     * @return The builder to allow chaining.
     */
    public Builder setApplicationLooper(Looper looper) {
      applicationLooper = checkNotNull(looper);
      return this;
    }

    /**
     * Builds a {@link MediaController} asynchronously.
     *
     * <p>The controller instance can be obtained like the following example:
     *
     * <pre>{@code
     * MediaController.Builder builder = ...;
     * ListenableFuture<MediaController> future = builder.buildAsync();
     * future.addListener(() -> {
     *   try {
     *     MediaController controller = future.get();
     *     // The session accepted the connection.
     *   } catch (ExecutionException e) {
     *     if (e.getCause() instanceof SecurityException) {
     *       // The session rejected the connection.
     *     }
     *   }
     * }, ContextCompat.getMainExecutor());
     * }</pre>
     *
     * <p>The future must be kept by callers until the future is complete to get the controller
     * instance. Otherwise, the future might be garbage collected and the listener added by {@link
     * ListenableFuture#addListener(Runnable, Executor)} would never be called.
     *
     * @return A future of the controller instance.
     */
    public ListenableFuture<MediaController> buildAsync() {
      MediaControllerHolder<MediaController> holder =
          new MediaControllerHolder<>(applicationLooper);
      MediaController controller =
          new MediaController(context, token, connectionHints, listener, applicationLooper, holder);
      postOrRun(new Handler(applicationLooper), () -> holder.setController(controller));
      return holder;
    }
  }

  /**
   * A listener for events and incoming commands from {@link MediaSession}.
   *
   * <p>The methods will be called from the application thread associated with the {@link
   * #getApplicationLooper() application looper} of the controller.
   */
  public interface Listener {

    /**
     * Called when the controller is disconnected from the session. The controller becomes
     * unavailable afterwards and this listener won't be called anymore.
     *
     * <p>It will be also called after the {@link #release()}, so you can put clean up code here.
     * You don't need to call {@link #release()} after this.
     *
     * @param controller The controller.
     */
    default void onDisconnected(MediaController controller) {}

    /**
     * Called when the session sets the custom layout through {@link MediaSession#setCustomLayout}.
     *
     * <p>Return a {@link ListenableFuture} to reply with a {@link SessionResult} to the session
     * asynchronously. You can also return a {@link SessionResult} directly by using Guava's {@link
     * Futures#immediateFuture(Object)}.
     *
     * <p>The default implementation returns a {@link ListenableFuture} of {@link
     * SessionResult#RESULT_ERROR_NOT_SUPPORTED}.
     *
     * @param controller The controller.
     * @param layout The ordered list of {@link CommandButton}.
     * @return The result of handling the custom layout.
     */
    default ListenableFuture<SessionResult> onSetCustomLayout(
        MediaController controller, List<CommandButton> layout) {
      return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED));
    }

    /**
     * Called when the available session commands are changed by session.
     *
     * @param controller The controller.
     * @param commands The new available session commands.
     */
    default void onAvailableSessionCommandsChanged(
        MediaController controller, SessionCommands commands) {}

    /**
     * Called when the session sends a custom command through {@link
     * MediaSession#sendCustomCommand}.
     *
     * <p>Return a {@link ListenableFuture} to reply with a {@link SessionResult} to the session
     * asynchronously. You can also return a {@link SessionResult} directly by using Guava's {@link
     * Futures#immediateFuture(Object)}.
     *
     * <p>The default implementation returns {@link ListenableFuture} of {@link
     * SessionResult#RESULT_ERROR_NOT_SUPPORTED}.
     *
     * @param controller The controller.
     * @param command The custom command.
     * @param args The additional arguments. May be empty.
     * @return The result of handling the custom command.
     */
    default ListenableFuture<SessionResult> onCustomCommand(
        MediaController controller, SessionCommand command, Bundle args) {
      return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED));
    }
  }

  /* package */ interface ConnectionCallback {

    void onAccepted();

    void onRejected();
  }

  private final Timeline.Window window;

  private boolean released;

  /* package */ final MediaControllerImpl impl;

  /* package */ final Listener listener;

  /* package */ final Handler applicationHandler;

  private long timeDiffMs;

  private boolean connectionNotified;

  /* package */ final ConnectionCallback connectionCallback;

  /** Creates a {@link MediaController} from the {@link SessionToken}. */
  /* package */ MediaController(
      Context context,
      SessionToken token,
      Bundle connectionHints,
      Listener listener,
      Looper applicationLooper,
      ConnectionCallback connectionCallback) {
    checkNotNull(context, "context must not be null");
    checkNotNull(token, "token must not be null");

    // Initialize default values.
    window = new Timeline.Window();
    timeDiffMs = C.TIME_UNSET;

    // Initialize members with params.
    this.listener = listener;
    applicationHandler = new Handler(applicationLooper);
    this.connectionCallback = connectionCallback;

    @SuppressWarnings("nullness:assignment")
    @Initialized
    MediaController thisRef = this;
    impl = thisRef.createImpl(context, thisRef, token, connectionHints);
    impl.connect();
  }

  /* package */ MediaControllerImpl createImpl(
      Context context, MediaController thisRef, SessionToken token, Bundle connectionHints) {
    if (token.isLegacySession()) {
      return new MediaControllerImplLegacy(context, thisRef, token);
    } else {
      return new MediaControllerImplBase(context, thisRef, token, connectionHints);
    }
  }

  @Override
  public void stop() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring stop().");
      return;
    }
    impl.stop();
  }

  @UnstableApi
  @Deprecated
  @Override
  public void stop(boolean reset) {
    throw new UnsupportedOperationException();
  }

  /**
   * Releases the connection between {@link MediaController} and {@link MediaSession}. This method
   * must be called when the controller is no longer required. The controller must not be used after
   * calling this method.
   *
   * <p>This method does not call {@link Player#release()} of the underlying player in the session.
   */
  @Override
  public void release() {
    verifyApplicationThread();
    if (released) {
      return;
    }
    released = true;
    try {
      impl.release();
    } catch (Exception e) {
      // Should not be here.
      Log.d(TAG, "Exception while releasing impl", e);
    }
    if (connectionNotified) {
      notifyControllerListener(listener -> listener.onDisconnected(this));
    } else {
      connectionNotified = true;
      connectionCallback.onRejected();
    }
  }

  /**
   * Releases the future controller returned by {@link Builder#buildAsync()}. It makes sure that the
   * controller is released by canceling the future if the future is not yet done.
   */
  public static void releaseFuture(Future<? extends MediaController> controllerFuture) {
    if (!controllerFuture.isDone()) {
      controllerFuture.cancel(/* mayInterruptIfRunning= */ true);
      return;
    }
    MediaController controller;
    try {
      controller = controllerFuture.get();
    } catch (CancellationException | ExecutionException | InterruptedException e) {
      return;
    }
    controller.release();
  }

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

  /** Returns whether this controller is connected to a {@link MediaSession} or not. */
  public boolean isConnected() {
    return impl.isConnected();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, then this will be grouped together with
   * previously called {@link #setMediaUri}. See {@link #setMediaUri} for details.
   */
  @Override
  public void play() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring play().");
      return;
    }
    impl.play();
  }

  @Override
  public void pause() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring pause().");
      return;
    }
    impl.pause();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, then this will be grouped together with
   * previously called {@link #setMediaUri}. See {@link #setMediaUri} for details.
   */
  @Override
  public void prepare() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring prepare().");
      return;
    }
    impl.prepare();
  }

  @Override
  public void seekToDefaultPosition() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekTo().");
      return;
    }
    impl.seekToDefaultPosition();
  }

  @Override
  public void seekToDefaultPosition(int mediaItemIndex) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekTo().");
      return;
    }
    impl.seekToDefaultPosition(mediaItemIndex);
  }

  @Override
  public void seekTo(long positionMs) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekTo().");
      return;
    }
    impl.seekTo(positionMs);
  }

  @Override
  public void seekTo(int mediaItemIndex, long positionMs) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekTo().");
      return;
    }
    impl.seekTo(mediaItemIndex, positionMs);
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link MediaSessionCompat}, it returns {code 0}.
   */
  @Override
  public long getSeekBackIncrement() {
    verifyApplicationThread();
    return isConnected() ? impl.getSeekBackIncrement() : 0;
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link MediaSessionCompat}, it calls {@link
   * MediaControllerCompat.TransportControls#rewind()}.
   */
  @Override
  public void seekBack() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekBack().");
      return;
    }
    impl.seekBack();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link MediaSessionCompat}, it returns {code 0}.
   */
  @Override
  public long getSeekForwardIncrement() {
    verifyApplicationThread();
    return isConnected() ? impl.getSeekForwardIncrement() : 0;
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link MediaSessionCompat}, it calls {@link
   * MediaControllerCompat.TransportControls#fastForward()}.
   */
  @Override
  public void seekForward() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekForward().");
      return;
    }
    impl.seekForward();
  }

  /** Returns an intent for launching UI associated with the session if exists, or {@code null}. */
  @Nullable
  public PendingIntent getSessionActivity() {
    return isConnected() ? impl.getSessionActivity() : null;
  }

  @Override
  @Nullable
  public PlaybackException getPlayerError() {
    verifyApplicationThread();
    return isConnected() ? impl.getPlayerError() : null;
  }

  @Override
  public void setPlayWhenReady(boolean playWhenReady) {
    verifyApplicationThread();
    if (isConnected()) {
      impl.setPlayWhenReady(playWhenReady);
    }
  }

  @Override
  public boolean getPlayWhenReady() {
    verifyApplicationThread();
    return isConnected() && impl.getPlayWhenReady();
  }

  @Override
  public @PlaybackSuppressionReason int getPlaybackSuppressionReason() {
    verifyApplicationThread();
    return isConnected()
        ? impl.getPlaybackSuppressionReason()
        : Player.PLAYBACK_SUPPRESSION_REASON_NONE;
  }

  @Override
  public @State int getPlaybackState() {
    verifyApplicationThread();
    return isConnected() ? impl.getPlaybackState() : Player.STATE_IDLE;
  }

  @Override
  public boolean isPlaying() {
    verifyApplicationThread();
    return isConnected() && impl.isPlaying();
  }

  @Override
  public boolean isLoading() {
    verifyApplicationThread();
    return isConnected() && impl.isLoading();
  }

  @Override
  public long getDuration() {
    verifyApplicationThread();
    return isConnected() ? impl.getDuration() : C.TIME_UNSET;
  }

  @Override
  public long getCurrentPosition() {
    verifyApplicationThread();
    return isConnected() ? impl.getCurrentPosition() : 0;
  }

  @Override
  public long getBufferedPosition() {
    verifyApplicationThread();
    return isConnected() ? impl.getBufferedPosition() : 0;
  }

  @Override
  @IntRange(from = 0, to = 100)
  public int getBufferedPercentage() {
    verifyApplicationThread();
    return isConnected() ? impl.getBufferedPercentage() : 0;
  }

  @Override
  public long getTotalBufferedDuration() {
    verifyApplicationThread();
    return isConnected() ? impl.getTotalBufferedDuration() : 0;
  }

  @Override
  public long getCurrentLiveOffset() {
    verifyApplicationThread();
    return isConnected() ? impl.getCurrentLiveOffset() : C.TIME_UNSET;
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, it's the same as {@link #getDuration()}
   * to match the behavior with {@link #getContentPosition()} and {@link
   * #getContentBufferedPosition()}.
   */
  @Override
  public long getContentDuration() {
    verifyApplicationThread();
    return isConnected() ? impl.getContentDuration() : C.TIME_UNSET;
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, it's the same as {@link
   * #getCurrentPosition()} because content position isn't available.
   */
  @Override
  public long getContentPosition() {
    verifyApplicationThread();
    return isConnected() ? impl.getContentPosition() : 0;
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, it's the same as {@link
   * #getBufferedPosition()} because content buffered position isn't available.
   */
  @Override
  public long getContentBufferedPosition() {
    verifyApplicationThread();
    return isConnected() ? impl.getContentBufferedPosition() : 0;
  }

  @Override
  public boolean isPlayingAd() {
    verifyApplicationThread();
    return isConnected() && impl.isPlayingAd();
  }

  @Override
  public int getCurrentAdGroupIndex() {
    verifyApplicationThread();
    return isConnected() ? impl.getCurrentAdGroupIndex() : C.INDEX_UNSET;
  }

  @Override
  public int getCurrentAdIndexInAdGroup() {
    verifyApplicationThread();
    return isConnected() ? impl.getCurrentAdIndexInAdGroup() : C.INDEX_UNSET;
  }

  @Override
  public void setPlaybackParameters(PlaybackParameters playbackParameters) {
    verifyApplicationThread();
    checkNotNull(playbackParameters, "playbackParameters must not be null");
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setPlaybackParameters().");
      return;
    }
    impl.setPlaybackParameters(playbackParameters);
  }

  @Override
  public void setPlaybackSpeed(float speed) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setPlaybackSpeed().");
      return;
    }
    impl.setPlaybackSpeed(speed);
  }

  @Override
  public PlaybackParameters getPlaybackParameters() {
    verifyApplicationThread();
    return isConnected() ? impl.getPlaybackParameters() : PlaybackParameters.DEFAULT;
  }

  @Override
  public AudioAttributes getAudioAttributes() {
    verifyApplicationThread();
    if (!isConnected()) {
      return AudioAttributes.DEFAULT;
    }
    return impl.getAudioAttributes();
  }

  /**
   * Requests that the connected {@link MediaSession} rates 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#userRating}.
   *
   * <p>If the user rating was {@code null}, the media item does not accept setting user rating.
   *
   * @param mediaId The non-empty {@link MediaItem#mediaId}.
   * @param rating The rating to set.
   * @return A {@link ListenableFuture} of {@link SessionResult} representing the pending
   *     completion.
   */
  public ListenableFuture<SessionResult> setRating(String mediaId, Rating rating) {
    verifyApplicationThread();
    checkNotNull(mediaId, "mediaId must not be null");
    checkNotEmpty(mediaId, "mediaId must not be empty");
    checkNotNull(rating, "rating must not be null");
    if (isConnected()) {
      return impl.setRating(mediaId, rating);
    }
    return createDisconnectedFuture();
  }

  /**
   * Requests that the connected {@link MediaSession} rates the current media item. 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#userRating}.
   *
   * <p>If the user rating was {@code null}, the media item does not accept setting user rating.
   *
   * @param rating The rating to set.
   * @return A {@link ListenableFuture} of {@link SessionResult} representing the pending
   *     completion.
   */
  public ListenableFuture<SessionResult> setRating(Rating rating) {
    verifyApplicationThread();
    checkNotNull(rating, "rating must not be null");
    if (isConnected()) {
      return impl.setRating(rating);
    }
    return createDisconnectedFuture();
  }

  /**
   * Sends a custom command to the session.
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, {@link SessionResult#resultCode} will
   * return the custom result code from the {@code android.os.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 The custom command.
   * @param args The additional arguments. May be empty.
   * @return A {@link ListenableFuture} of {@link SessionResult} representing the pending
   *     completion.
   */
  public ListenableFuture<SessionResult> sendCustomCommand(SessionCommand command, Bundle args) {
    verifyApplicationThread();
    checkNotNull(command, "command must not be null");
    checkArgument(
        command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM,
        "command must be a custom command");
    if (isConnected()) {
      return impl.sendCustomCommand(command, args);
    }
    return createDisconnectedFuture();
  }

  /** Returns {@code null}. */
  @UnstableApi
  @Override
  @Nullable
  public Object getCurrentManifest() {
    return null;
  }

  /**
   * {@inheritDoc}
   *
   * <p>Caveat: Some methods of the {@link Timeline} such as {@link Timeline#getPeriodByUid(Object,
   * Timeline.Period)}, {@link Timeline#getIndexOfPeriod(Object)}, and {@link
   * Timeline#getUidOfPeriod(int)} will throw {@link UnsupportedOperationException} because of the
   * limitation of restoring the instance sent from session as described in {@link
   * Timeline#CREATOR}.
   */
  @Override
  public Timeline getCurrentTimeline() {
    verifyApplicationThread();
    return isConnected() ? impl.getCurrentTimeline() : Timeline.EMPTY;
  }

  @Override
  public void setMediaItem(MediaItem mediaItem) {
    verifyApplicationThread();
    checkNotNull(mediaItem, "mediaItems must not be null");
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setMediaItem().");
      return;
    }
    impl.setMediaItem(mediaItem);
  }

  @Override
  public void setMediaItem(MediaItem mediaItem, long startPositionMs) {
    verifyApplicationThread();
    checkNotNull(mediaItem, "mediaItems must not be null");
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setMediaItem().");
      return;
    }
    impl.setMediaItem(mediaItem, startPositionMs);
  }

  @Override
  public void setMediaItem(MediaItem mediaItem, boolean resetPosition) {
    verifyApplicationThread();
    checkNotNull(mediaItem, "mediaItems must not be null");
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setMediaItems().");
      return;
    }
    impl.setMediaItem(mediaItem, resetPosition);
  }

  @Override
  public void setMediaItems(List<MediaItem> mediaItems) {
    verifyApplicationThread();
    checkNotNull(mediaItems, "mediaItems must not be null");
    for (int i = 0; i < mediaItems.size(); i++) {
      checkArgument(mediaItems.get(i) != null, "items must not contain null, index=" + i);
    }
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setMediaItems().");
      return;
    }
    impl.setMediaItems(mediaItems);
  }

  @Override
  public void setMediaItems(List<MediaItem> mediaItems, boolean resetPosition) {
    verifyApplicationThread();
    checkNotNull(mediaItems, "mediaItems must not be null");
    for (int i = 0; i < mediaItems.size(); i++) {
      checkArgument(mediaItems.get(i) != null, "items must not contain null, index=" + i);
    }
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setMediaItems().");
      return;
    }
    impl.setMediaItems(mediaItems, resetPosition);
  }

  @Override
  public void setMediaItems(List<MediaItem> mediaItems, int startIndex, long startPositionMs) {
    verifyApplicationThread();
    checkNotNull(mediaItems, "mediaItems must not be null");
    for (int i = 0; i < mediaItems.size(); i++) {
      checkArgument(mediaItems.get(i) != null, "items must not contain null, index=" + i);
    }
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setMediaItems().");
      return;
    }
    impl.setMediaItems(mediaItems, startIndex, startPositionMs);
  }

  /**
   * Requests that the connected {@link MediaSession} sets a specific {@link Uri} for playback. Use
   * this, or {@link #setMediaItems} to specify which item(s) to play.
   *
   * <p>This can be called multiple times in any states. This would override previous call of this,
   * or {@link #setMediaItems}.
   *
   * <p>The {@link Player.Listener#onTimelineChanged} and/or {@link
   * Player.Listener#onMediaItemTransition} would be called when it's completed.
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, this call will be grouped together with
   * later {@link #prepare} or {@link #play}, depending on the uri pattern as follows:
   *
   * <table>
   * <caption>Uri patterns and following API calls for MediaControllerCompat methods</caption>
   * <tr>
   * <th>Uri patterns</th><th>Following API calls</th><th>Method</th>
   * </tr><tr>
   * <td rowspan="2">{@code androidx://media3-session/setMediaUri?uri=[uri]}</td>
   * <td>{@link #prepare}</td>
   * <td>{@link MediaControllerCompat.TransportControls#prepareFromUri prepareFromUri}
   * </tr><tr>
   * <td>{@link #play}</td>
   * <td>{@link MediaControllerCompat.TransportControls#playFromUri playFromUri}
   * </tr><tr>
   * <td rowspan="2">{@code androidx://media3-session/setMediaUri?id=[mediaId]}</td>
   * <td>{@link #prepare}</td>
   * <td>{@link MediaControllerCompat.TransportControls#prepareFromMediaId prepareFromMediaId}
   * </tr><tr>
   * <td>{@link #play}</td>
   * <td>{@link MediaControllerCompat.TransportControls#playFromMediaId playFromMediaId}
   * </tr><tr>
   * <td rowspan="2">{@code androidx://media3-session/setMediaUri?query=[query]}</td>
   * <td>{@link #prepare}</td>
   * <td>{@link MediaControllerCompat.TransportControls#prepareFromSearch prepareFromSearch}
   * </tr><tr>
   * <td>{@link #play}</td>
   * <td>{@link MediaControllerCompat.TransportControls#playFromSearch playFromSearch}
   * </tr><tr>
   * <td rowspan="2">Does not match with any pattern above</td>
   * <td>{@link #prepare}</td>
   * <td>{@link MediaControllerCompat.TransportControls#prepareFromUri prepareFromUri}
   * </tr><tr>
   * <td>{@link #play}</td>
   * <td>{@link MediaControllerCompat.TransportControls#playFromUri playFromUri}
   * </tr></table>
   *
   * <p>Returned {@link ListenableFuture} will return {@link SessionResult#RESULT_SUCCESS} when it's
   * handled together with {@link #prepare} or {@link #play}. If this API is called multiple times
   * without prepare or play, then {@link SessionResult#RESULT_INFO_SKIPPED} will be returned for
   * previous calls.
   *
   * @param uri The uri of the item(s) to play.
   * @param extras A {@link Bundle} to send extra information. May be empty.
   * @return A {@link ListenableFuture} of {@link SessionResult} representing the pending
   *     completion.
   * @see MediaConstants#MEDIA_URI_AUTHORITY
   * @see MediaConstants#MEDIA_URI_PATH_PREPARE_FROM_MEDIA_ID
   * @see MediaConstants#MEDIA_URI_PATH_PLAY_FROM_MEDIA_ID
   * @see MediaConstants#MEDIA_URI_PATH_PREPARE_FROM_SEARCH
   * @see MediaConstants#MEDIA_URI_PATH_PLAY_FROM_SEARCH
   * @see MediaConstants#MEDIA_URI_PATH_SET_MEDIA_URI
   * @see MediaConstants#MEDIA_URI_QUERY_ID
   * @see MediaConstants#MEDIA_URI_QUERY_QUERY
   * @see MediaConstants#MEDIA_URI_QUERY_URI
   */
  public ListenableFuture<SessionResult> setMediaUri(Uri uri, Bundle extras) {
    verifyApplicationThread();
    checkNotNull(uri);
    checkNotNull(extras);
    if (isConnected()) {
      return impl.setMediaUri(uri, extras);
    }
    return createDisconnectedFuture();
  }

  @Override
  public void setPlaylistMetadata(MediaMetadata playlistMetadata) {
    verifyApplicationThread();
    checkNotNull(playlistMetadata, "playlistMetadata must not be null");
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setPlaylistMetadata().");
      return;
    }
    impl.setPlaylistMetadata(playlistMetadata);
  }

  @Override
  public MediaMetadata getPlaylistMetadata() {
    verifyApplicationThread();
    return isConnected() ? impl.getPlaylistMetadata() : MediaMetadata.EMPTY;
  }

  @Override
  public void addMediaItem(MediaItem mediaItem) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring addMediaItem().");
      return;
    }
    impl.addMediaItem(mediaItem);
  }

  @Override
  public void addMediaItem(int index, MediaItem mediaItem) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring addMediaItem().");
      return;
    }
    impl.addMediaItem(index, mediaItem);
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically add items.
   */
  @Override
  public void addMediaItems(List<MediaItem> mediaItems) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring addMediaItems().");
      return;
    }
    impl.addMediaItems(mediaItems);
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically add items.
   */
  @Override
  public void addMediaItems(int index, List<MediaItem> mediaItems) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring addMediaItems().");
      return;
    }
    impl.addMediaItems(index, mediaItems);
  }

  @Override
  public void removeMediaItem(int index) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring removeMediaItem().");
      return;
    }
    impl.removeMediaItem(index);
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically remove items.
   */
  @Override
  public void removeMediaItems(int fromIndex, int toIndex) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring removeMediaItems().");
      return;
    }
    impl.removeMediaItems(fromIndex, toIndex);
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically clear items.
   */
  @Override
  public void clearMediaItems() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring clearMediaItems().");
      return;
    }
    impl.clearMediaItems();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically move items.
   */
  @Override
  public void moveMediaItem(int currentIndex, int newIndex) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring moveMediaItem().");
      return;
    }
    impl.moveMediaItem(currentIndex, newIndex);
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically move items.
   */
  @Override
  public void moveMediaItems(int fromIndex, int toIndex, int newIndex) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring moveMediaItems().");
      return;
    }
    impl.moveMediaItems(fromIndex, toIndex, newIndex);
  }

  @UnstableApi
  @Deprecated
  @Override
  public boolean isCurrentWindowDynamic() {
    throw new UnsupportedOperationException();
  }

  @Override
  public boolean isCurrentMediaItemDynamic() {
    verifyApplicationThread();
    Timeline timeline = getCurrentTimeline();
    return !timeline.isEmpty() && timeline.getWindow(getCurrentMediaItemIndex(), window).isDynamic;
  }

  @UnstableApi
  @Deprecated
  @Override
  public boolean isCurrentWindowLive() {
    throw new UnsupportedOperationException();
  }

  @Override
  public boolean isCurrentMediaItemLive() {
    verifyApplicationThread();
    Timeline timeline = getCurrentTimeline();
    return !timeline.isEmpty() && timeline.getWindow(getCurrentMediaItemIndex(), window).isLive();
  }

  @UnstableApi
  @Deprecated
  @Override
  public boolean isCurrentWindowSeekable() {
    throw new UnsupportedOperationException();
  }

  @Override
  public boolean isCurrentMediaItemSeekable() {
    verifyApplicationThread();
    Timeline timeline = getCurrentTimeline();
    return !timeline.isEmpty() && timeline.getWindow(getCurrentMediaItemIndex(), window).isSeekable;
  }

  /**
   * {@inheritDoc}
   *
   * <p>The MediaController returns {@code false}.
   */
  @Override
  public boolean canAdvertiseSession() {
    return false;
  }

  @Override
  @Nullable
  public MediaItem getCurrentMediaItem() {
    Timeline timeline = getCurrentTimeline();
    return timeline.isEmpty()
        ? null
        : timeline.getWindow(getCurrentMediaItemIndex(), window).mediaItem;
  }

  @Override
  public int getMediaItemCount() {
    return getCurrentTimeline().getWindowCount();
  }

  @Override
  public MediaItem getMediaItemAt(int index) {
    return getCurrentTimeline().getWindow(index, window).mediaItem;
  }

  @Override
  public int getCurrentPeriodIndex() {
    verifyApplicationThread();
    return isConnected() ? impl.getCurrentPeriodIndex() : C.INDEX_UNSET;
  }

  @UnstableApi
  @Deprecated
  @Override
  public int getCurrentWindowIndex() {
    throw new UnsupportedOperationException();
  }

  @Override
  public int getCurrentMediaItemIndex() {
    verifyApplicationThread();
    return isConnected() ? impl.getCurrentMediaItemIndex() : C.INDEX_UNSET;
  }

  @UnstableApi
  @Deprecated
  @Override
  public int getPreviousWindowIndex() {
    throw new UnsupportedOperationException();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, this will always return {@link
   * C#INDEX_UNSET} even when {@link #hasPreviousMediaItem()} is {@code true}.
   */
  @Override
  public int getPreviousMediaItemIndex() {
    verifyApplicationThread();
    return isConnected() ? impl.getPreviousMediaItemIndex() : C.INDEX_UNSET;
  }

  @UnstableApi
  @Deprecated
  @Override
  public int getNextWindowIndex() {
    throw new UnsupportedOperationException();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, this will always return {@link
   * C#INDEX_UNSET} even when {@link #hasNextMediaItem()} is {@code true}.
   */
  @Override
  public int getNextMediaItemIndex() {
    verifyApplicationThread();
    return isConnected() ? impl.getNextMediaItemIndex() : C.INDEX_UNSET;
  }

  @UnstableApi
  @Deprecated
  @Override
  public boolean hasPrevious() {
    throw new UnsupportedOperationException();
  }

  @UnstableApi
  @Deprecated
  @Override
  public boolean hasNext() {
    throw new UnsupportedOperationException();
  }

  @UnstableApi
  @Deprecated
  @Override
  public boolean hasPreviousWindow() {
    throw new UnsupportedOperationException();
  }

  @UnstableApi
  @Deprecated
  @Override
  public boolean hasNextWindow() {
    throw new UnsupportedOperationException();
  }

  @Override
  public boolean hasPreviousMediaItem() {
    verifyApplicationThread();
    return isConnected() && impl.hasPreviousMediaItem();
  }

  @Override
  public boolean hasNextMediaItem() {
    verifyApplicationThread();
    return isConnected() && impl.hasNextMediaItem();
  }

  @UnstableApi
  @Deprecated
  @Override
  public void previous() {
    throw new UnsupportedOperationException();
  }

  @UnstableApi
  @Deprecated
  @Override
  public void next() {
    throw new UnsupportedOperationException();
  }

  @UnstableApi
  @Deprecated
  @Override
  public void seekToPreviousWindow() {
    throw new UnsupportedOperationException();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, it's the same as {@link #seekToPrevious}.
   */
  @Override
  public void seekToPreviousMediaItem() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekToPreviousMediaItem().");
      return;
    }
    impl.seekToPreviousMediaItem();
  }

  @UnstableApi
  @Deprecated
  @Override
  public void seekToNextWindow() {
    throw new UnsupportedOperationException();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, it's the same as {@link #seekToNext}.
   */
  @Override
  public void seekToNextMediaItem() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekToNextMediaItem().");
      return;
    }
    impl.seekToNextMediaItem();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, it won't update the current media item
   * index immediately because the previous media item index is unknown.
   */
  @Override
  public void seekToPrevious() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekToPrevious().");
      return;
    }
    impl.seekToPrevious();
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, it always returns {@code 0}.
   */
  @Override
  public long getMaxSeekToPreviousPosition() {
    verifyApplicationThread();
    return isConnected() ? impl.getMaxSeekToPreviousPosition() : 0L;
  }

  /**
   * {@inheritDoc}
   *
   * <p>Interoperability: When connected to {@link
   * android.support.v4.media.session.MediaSessionCompat}, it won't update the current media item
   * index immediately because the previous media item index is unknown.
   */
  @Override
  public void seekToNext() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring seekToNext().");
      return;
    }
    impl.seekToNext();
  }

  @Override
  public @RepeatMode int getRepeatMode() {
    verifyApplicationThread();
    return isConnected() ? impl.getRepeatMode() : Player.REPEAT_MODE_OFF;
  }

  @Override
  public void setRepeatMode(@RepeatMode int repeatMode) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setRepeatMode().");
      return;
    }
    impl.setRepeatMode(repeatMode);
  }

  @Override
  public boolean getShuffleModeEnabled() {
    verifyApplicationThread();
    return isConnected() && impl.getShuffleModeEnabled();
  }

  @Override
  public void setShuffleModeEnabled(boolean shuffleModeEnabled) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setShuffleMode().");
      return;
    }
    impl.setShuffleModeEnabled(shuffleModeEnabled);
  }

  @Override
  public VideoSize getVideoSize() {
    verifyApplicationThread();
    return isConnected() ? impl.getVideoSize() : VideoSize.UNKNOWN;
  }

  @Override
  public void clearVideoSurface() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring clearVideoSurface().");
      return;
    }
    impl.clearVideoSurface();
  }

  @Override
  public void clearVideoSurface(@Nullable Surface surface) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring clearVideoSurface().");
      return;
    }
    impl.clearVideoSurface(surface);
  }

  @Override
  public void setVideoSurface(@Nullable Surface surface) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setVideoSurface().");
      return;
    }
    impl.setVideoSurface(surface);
  }

  @Override
  public void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setVideoSurfaceHolder().");
      return;
    }
    impl.setVideoSurfaceHolder(surfaceHolder);
  }

  @Override
  public void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring clearVideoSurfaceHolder().");
      return;
    }
    impl.clearVideoSurfaceHolder(surfaceHolder);
  }

  @Override
  public void setVideoSurfaceView(@Nullable SurfaceView surfaceView) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setVideoSurfaceView().");
      return;
    }
    impl.setVideoSurfaceView(surfaceView);
  }

  @Override
  public void clearVideoSurfaceView(@Nullable SurfaceView surfaceView) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring clearVideoSurfaceView().");
      return;
    }
    impl.clearVideoSurfaceView(surfaceView);
  }

  @Override
  public void setVideoTextureView(@Nullable TextureView textureView) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setVideoTextureView().");
      return;
    }
    impl.setVideoTextureView(textureView);
  }

  @Override
  public void clearVideoTextureView(@Nullable TextureView textureView) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring clearVideoTextureView().");
      return;
    }
    impl.clearVideoTextureView(textureView);
  }

  @Override
  public List<Cue> getCurrentCues() {
    verifyApplicationThread();
    return isConnected() ? impl.getCurrentCues() : ImmutableList.of();
  }

  @Override
  @FloatRange(from = 0, to = 1)
  public float getVolume() {
    verifyApplicationThread();
    return isConnected() ? impl.getVolume() : 1;
  }

  @Override
  public void setVolume(@FloatRange(from = 0, to = 1) float volume) {
    verifyApplicationThread();
    checkArgument(volume >= 0 && volume <= 1, "volume must be between 0 and 1");
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setVolume().");
      return;
    }
    impl.setVolume(volume);
  }

  @Override
  public DeviceInfo getDeviceInfo() {
    verifyApplicationThread();
    if (!isConnected()) {
      return DeviceInfo.UNKNOWN;
    }
    return impl.getDeviceInfo();
  }

  @Override
  @IntRange(from = 0)
  public int getDeviceVolume() {
    verifyApplicationThread();
    if (!isConnected()) {
      return 0;
    }
    return impl.getDeviceVolume();
  }

  @Override
  public boolean isDeviceMuted() {
    verifyApplicationThread();
    if (!isConnected()) {
      return false;
    }
    return impl.isDeviceMuted();
  }

  @Override
  public void setDeviceVolume(@IntRange(from = 0) int volume) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setDeviceVolume().");
      return;
    }
    impl.setDeviceVolume(volume);
  }

  @Override
  public void increaseDeviceVolume() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring increaseDeviceVolume().");
      return;
    }
    impl.increaseDeviceVolume();
  }

  @Override
  public void decreaseDeviceVolume() {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring decreaseDeviceVolume().");
      return;
    }
    impl.decreaseDeviceVolume();
  }

  @Override
  public void setDeviceMuted(boolean muted) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setDeviceMuted().");
      return;
    }
    impl.setDeviceMuted(muted);
  }

  @Override
  public MediaMetadata getMediaMetadata() {
    verifyApplicationThread();
    return isConnected() ? impl.getMediaMetadata() : MediaMetadata.EMPTY;
  }

  /** Returns {@link TrackGroupArray#EMPTY}. */
  @UnstableApi
  @Override
  public TrackGroupArray getCurrentTrackGroups() {
    return TrackGroupArray.EMPTY;
  }

  /** Returns an empty {@link TrackSelectionArray}. */
  @UnstableApi
  @Override
  public TrackSelectionArray getCurrentTrackSelections() {
    return new TrackSelectionArray();
  }

  @Override
  public TracksInfo getCurrentTracksInfo() {
    return TracksInfo.EMPTY; // TODO(b/178486745)
  }

  @Override
  public TrackSelectionParameters getTrackSelectionParameters() {
    verifyApplicationThread();
    if (!isConnected()) {
      return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT;
    }
    return impl.getTrackSelectionParameters();
  }

  @Override
  public void setTrackSelectionParameters(TrackSelectionParameters parameters) {
    verifyApplicationThread();
    if (!isConnected()) {
      Log.w(TAG, "The controller is not connected. Ignoring setTrackSelectionParameters().");
    }
    impl.setTrackSelectionParameters(parameters);
  }

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

  /**
   * Gets the optional time diff (in milliseconds) used for calculating the current position, or
   * {@link C#TIME_UNSET} if no diff should be applied.
   */
  /* package */ long getTimeDiffMs() {
    return timeDiffMs;
  }

  /**
   * Sets the time diff (in milliseconds) used when calculating the current position.
   *
   * @param timeDiffMs {@link C#TIME_UNSET} for reset.
   */
  @VisibleForTesting(otherwise = NONE)
  /* package */ void setTimeDiffMs(long timeDiffMs) {
    verifyApplicationThread();
    this.timeDiffMs = timeDiffMs;
  }

  @Override
  public void addListener(Player.Listener listener) {
    checkNotNull(listener, "listener must not be null");
    impl.addListener(listener);
  }

  @Override
  public void removeListener(Player.Listener listener) {
    checkNotNull(listener, "listener must not be null");
    impl.removeListener(listener);
  }

  @Override
  public boolean isCommandAvailable(@Command int command) {
    return getAvailableCommands().contains(command);
  }

  @Override
  public Commands getAvailableCommands() {
    verifyApplicationThread();
    if (!isConnected()) {
      return Commands.EMPTY;
    }
    return impl.getAvailableCommands();
  }

  /**
   * Returns whether the {@link SessionCommand.CommandCode} is available. The {@code
   * sessionCommandCode} must not be {@link SessionCommand#COMMAND_CODE_CUSTOM}. Use {@link
   * #isSessionCommandAvailable(SessionCommand)} for custom commands.
   */
  public boolean isSessionCommandAvailable(@SessionCommand.CommandCode int sessionCommandCode) {
    return getAvailableSessionCommands().contains(sessionCommandCode);
  }

  /** Returns whether the {@link SessionCommand} is available. */
  public boolean isSessionCommandAvailable(SessionCommand sessionCommand) {
    return getAvailableSessionCommands().contains(sessionCommand);
  }

  /**
   * Returns the current available session commands from {@link
   * Listener#onAvailableSessionCommandsChanged(MediaController, SessionCommands)}, or {@link
   * SessionCommands#EMPTY} if it is not connected.
   *
   * @return The available session commands.
   */
  public SessionCommands getAvailableSessionCommands() {
    verifyApplicationThread();
    if (!isConnected()) {
      return SessionCommands.EMPTY;
    }
    return impl.getAvailableSessionCommands();
  }

  private static ListenableFuture<SessionResult> createDisconnectedFuture() {
    return Futures.immediateFuture(
        new SessionResult(SessionResult.RESULT_ERROR_SESSION_DISCONNECTED));
  }

  /* package */ void runOnApplicationLooper(Runnable runnable) {
    postOrRun(applicationHandler, runnable);
  }

  /* package */ void notifyControllerListener(Consumer<Listener> listenerConsumer) {
    checkState(Looper.myLooper() == getApplicationLooper());
    listenerConsumer.accept(listener);
  }

  /* package */ void notifyAccepted() {
    checkState(Looper.myLooper() == getApplicationLooper());
    checkState(!connectionNotified);
    connectionNotified = true;
    connectionCallback.onAccepted();
  }

  private void verifyApplicationThread() {
    checkState(Looper.myLooper() == getApplicationLooper(), WRONG_THREAD_ERROR_MESSAGE);
  }

  interface MediaControllerImpl {

    void connect();

    void addListener(Player.Listener listener);

    void removeListener(Player.Listener listener);

    @Nullable
    SessionToken getConnectedToken();

    boolean isConnected();

    void play();

    void pause();

    void setPlayWhenReady(boolean playWhenReady);

    void prepare();

    void stop();

    void release();

    void seekToDefaultPosition();

    void seekToDefaultPosition(int mediaItemIndex);

    void seekTo(long positionMs);

    void seekTo(int mediaItemIndex, long positionMs);

    long getSeekBackIncrement();

    void seekBack();

    long getSeekForwardIncrement();

    void seekForward();

    @Nullable
    PendingIntent getSessionActivity();

    @Nullable
    PlaybackException getPlayerError();

    long getDuration();

    long getCurrentPosition();

    long getBufferedPosition();

    int getBufferedPercentage();

    long getTotalBufferedDuration();

    long getCurrentLiveOffset();

    long getContentDuration();

    long getContentPosition();

    long getContentBufferedPosition();

    boolean isPlayingAd();

    int getCurrentAdGroupIndex();

    int getCurrentAdIndexInAdGroup();

    void setPlaybackParameters(PlaybackParameters playbackParameters);

    void setPlaybackSpeed(float speed);

    PlaybackParameters getPlaybackParameters();

    AudioAttributes getAudioAttributes();

    ListenableFuture<SessionResult> setRating(String mediaId, Rating rating);

    ListenableFuture<SessionResult> setRating(Rating rating);

    ListenableFuture<SessionResult> sendCustomCommand(SessionCommand command, Bundle args);

    Timeline getCurrentTimeline();

    void setMediaItem(MediaItem mediaItem);

    void setMediaItem(MediaItem mediaItem, long startPositionMs);

    void setMediaItem(MediaItem mediaItem, boolean resetPosition);

    void setMediaItems(List<MediaItem> mediaItems);

    void setMediaItems(List<MediaItem> mediaItems, boolean resetPosition);

    void setMediaItems(List<MediaItem> mediaItems, int startIndex, long startPositionMs);

    ListenableFuture<SessionResult> setMediaUri(Uri uri, Bundle extras);

    void setPlaylistMetadata(MediaMetadata playlistMetadata);

    MediaMetadata getPlaylistMetadata();

    void addMediaItem(MediaItem mediaItem);

    void addMediaItem(int index, MediaItem mediaItem);

    void addMediaItems(List<MediaItem> mediaItems);

    void addMediaItems(int index, List<MediaItem> mediaItems);

    void removeMediaItem(int index);

    void removeMediaItems(int fromIndex, int toIndex);

    void clearMediaItems();

    void moveMediaItem(int currentIndex, int newIndex);

    void moveMediaItems(int fromIndex, int toIndex, int newIndex);

    int getCurrentPeriodIndex();

    int getCurrentMediaItemIndex();

    int getPreviousMediaItemIndex();

    int getNextMediaItemIndex();

    boolean hasPreviousMediaItem();

    boolean hasNextMediaItem();

    void seekToPreviousMediaItem();

    void seekToNextMediaItem();

    void seekToPrevious();

    long getMaxSeekToPreviousPosition();

    void seekToNext();

    @RepeatMode
    int getRepeatMode();

    void setRepeatMode(@RepeatMode int repeatMode);

    boolean getShuffleModeEnabled();

    void setShuffleModeEnabled(boolean shuffleModeEnabled);

    VideoSize getVideoSize();

    void clearVideoSurface();

    void clearVideoSurface(@Nullable Surface surface);

    void setVideoSurface(@Nullable Surface surface);

    void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder);

    void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder);

    void setVideoSurfaceView(@Nullable SurfaceView surfaceView);

    void clearVideoSurfaceView(@Nullable SurfaceView surfaceView);

    void setVideoTextureView(@Nullable TextureView textureView);

    void clearVideoTextureView(@Nullable TextureView textureView);

    List<Cue> getCurrentCues();

    float getVolume();

    void setVolume(float volume);

    DeviceInfo getDeviceInfo();

    int getDeviceVolume();

    boolean isDeviceMuted();

    void setDeviceVolume(int volume);

    void increaseDeviceVolume();

    void decreaseDeviceVolume();

    void setDeviceMuted(boolean muted);

    boolean getPlayWhenReady();

    @PlaybackSuppressionReason
    int getPlaybackSuppressionReason();

    @State
    int getPlaybackState();

    boolean isPlaying();

    boolean isLoading();

    MediaMetadata getMediaMetadata();

    Commands getAvailableCommands();

    TrackSelectionParameters getTrackSelectionParameters();

    void setTrackSelectionParameters(TrackSelectionParameters parameters);

    SessionCommands getAvailableSessionCommands();

    // Internally used methods
    Context getContext();

    @Nullable
    MediaBrowserCompat getBrowserCompat();
  }
}