de.danoeh.antennapod.core.cast.CastManager.java Source code

Java tutorial

Introduction

Here is the source code for de.danoeh.antennapod.core.cast.CastManager.java

Source

/*
 * Copyright (C) 2015 Google Inc. All Rights Reserved.
 *
 * 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.
 *
 * ------------------------------------------------------------------------
 *
 * Changes made by Domingos Lopes <domingos86lopes@gmail.com>
 *
 * original can be found at http://www.github.com/googlecast/CastCompanionLibrary-android
 */

package de.danoeh.antennapod.core.cast;

import android.content.Context;
import android.os.Build;
import android.support.v4.view.MenuItemCompat;
import android.support.v7.media.MediaRouter;
import android.util.Log;
import android.view.KeyEvent;
import android.view.MenuItem;

import com.google.android.gms.cast.ApplicationMetadata;
import com.google.android.gms.cast.Cast;
import com.google.android.gms.cast.CastDevice;
import com.google.android.gms.cast.CastMediaControlIntent;
import com.google.android.gms.cast.CastStatusCodes;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaMetadata;
import com.google.android.gms.cast.MediaQueueItem;
import com.google.android.gms.cast.MediaStatus;
import com.google.android.gms.cast.RemoteMediaPlayer;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
import com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager;
import com.google.android.libraries.cast.companionlibrary.cast.CastConfiguration;
import com.google.android.libraries.cast.companionlibrary.cast.MediaQueue;
import com.google.android.libraries.cast.companionlibrary.cast.exceptions.CastException;
import com.google.android.libraries.cast.companionlibrary.cast.exceptions.NoConnectionException;
import com.google.android.libraries.cast.companionlibrary.cast.exceptions.OnFailedListener;
import com.google.android.libraries.cast.companionlibrary.cast.exceptions.TransientNetworkDisconnectionException;

import org.json.JSONObject;

import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.TimeUnit;

import de.danoeh.antennapod.core.R;

import static com.google.android.gms.cast.RemoteMediaPlayer.RESUME_STATE_PLAY;
import static com.google.android.gms.cast.RemoteMediaPlayer.RESUME_STATE_UNCHANGED;

/**
 * A subclass of {@link BaseCastManager} that is suitable for casting video contents (it
 * also provides a single custom data channel/namespace if an out-of-band communication is
 * needed).
 * <p>
 * Clients need to initialize this class by calling
 * {@link #init(android.content.Context)} in the Application's
 * {@code onCreate()} method. To access the (singleton) instance of this class, clients
 * need to call {@link #getInstance()}.
 * <p>This
 * class manages various states of the remote cast device. Client applications, however, can
 * complement the default behavior of this class by hooking into various callbacks that it provides
 * (see {@link CastConsumer}).
 * Since the number of these callbacks is usually much larger than what a single application might
 * be interested in, there is a no-op implementation of this interface (see
 * {@link DefaultCastConsumer}) that applications can subclass to override only those methods that
 * they are interested in. Since this library depends on the cast functionalities provided by the
 * Google Play services, the library checks to ensure that the right version of that service is
 * installed. It also provides a simple static method {@code checkGooglePlayServices()} that clients
 * can call at an early stage of their applications to provide a dialog for users if they need to
 * update/activate their Google Play Services library.
 *
 * @see CastConfiguration
 */
public class CastManager extends BaseCastManager implements OnFailedListener {
    public static final String TAG = "CastManager";

    public static final String CAST_APP_ID = CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID;

    public static final double DEFAULT_VOLUME_STEP = 0.05;
    public static final long DEFAULT_LIVE_STREAM_DURATION_MS = TimeUnit.HOURS.toMillis(2);
    private double volumeStep = DEFAULT_VOLUME_STEP;
    private MediaQueue mediaQueue;
    private MediaStatus mediaStatus;

    private static CastManager INSTANCE;
    private RemoteMediaPlayer remoteMediaPlayer;
    private int state = MediaStatus.PLAYER_STATE_IDLE;
    private int idleReason;
    private final Set<CastConsumer> castConsumers = new CopyOnWriteArraySet<>();
    private long liveStreamDuration = DEFAULT_LIVE_STREAM_DURATION_MS;
    private MediaQueueItem preLoadingItem;

    public static final int QUEUE_OPERATION_LOAD = 1;
    public static final int QUEUE_OPERATION_INSERT_ITEMS = 2;
    public static final int QUEUE_OPERATION_UPDATE_ITEMS = 3;
    public static final int QUEUE_OPERATION_JUMP = 4;
    public static final int QUEUE_OPERATION_REMOVE_ITEM = 5;
    public static final int QUEUE_OPERATION_REMOVE_ITEMS = 6;
    public static final int QUEUE_OPERATION_REORDER = 7;
    public static final int QUEUE_OPERATION_MOVE = 8;
    public static final int QUEUE_OPERATION_APPEND = 9;
    public static final int QUEUE_OPERATION_NEXT = 10;
    public static final int QUEUE_OPERATION_PREV = 11;
    public static final int QUEUE_OPERATION_SET_REPEAT = 12;

    private CastManager(Context context, CastConfiguration castConfiguration) {
        super(context, castConfiguration);
        Log.d(TAG, "CastManager is instantiated");
    }

    public static synchronized CastManager init(Context context) {
        if (INSTANCE == null) {
            //TODO also setup dialog factory if necessary
            CastConfiguration castConfiguration = new CastConfiguration.Builder(CAST_APP_ID).enableDebug()
                    .enableAutoReconnect().enableWifiReconnection().setLaunchOptions(true, Locale.getDefault())
                    .build();
            Log.d(TAG, "New instance of CastManager is created");
            if (ConnectionResult.SUCCESS != GoogleApiAvailability.getInstance()
                    .isGooglePlayServicesAvailable(context)) {
                Log.e(TAG, "Couldn't find the appropriate version of Google Play Services");
                //TODO check whether creating an instance without google play services installed actually gives an exception
            }
            INSTANCE = new CastManager(context, castConfiguration);
        }
        return INSTANCE;
    }

    /**
     * Returns a (singleton) instance of this class. Clients should call this method in order to
     * get a hold of this singleton instance, only after it is initialized. If it is not initialized
     * yet, an {@link IllegalStateException} will be thrown.
     *
     */
    public static CastManager getInstance() {
        if (INSTANCE == null) {
            String msg = "No CastManager instance was found, did you forget to initialize it?";
            Log.e(TAG, msg);
            throw new IllegalStateException(msg);
        }
        return INSTANCE;
    }

    /**
     * Returns the active {@link RemoteMediaPlayer} instance. Since there are a number of media
     * control APIs that this library do not provide a wrapper for, client applications can call
     * those methods directly after obtaining an instance of the active {@link RemoteMediaPlayer}.
     */
    public final RemoteMediaPlayer getRemoteMediaPlayer() {
        return remoteMediaPlayer;
    }

    /**
     * Determines if the media that is loaded remotely is a live stream or not.
     *
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public final boolean isRemoteStreamLive() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        MediaInfo info = getRemoteMediaInformation();
        return (info != null) && (info.getStreamType() == MediaInfo.STREAM_TYPE_LIVE);
    }

    /*
     * A simple check to make sure remoteMediaPlayer is not null
     */
    private void checkRemoteMediaPlayerAvailable() throws NoConnectionException {
        if (remoteMediaPlayer == null) {
            throw new NoConnectionException();
        }
    }

    /**
     * Returns the url for the media that is currently playing on the remote device. If there is no
     * connection, this will return <code>null</code>.
     *
     * @throws NoConnectionException If no connectivity to the device exists
     * @throws TransientNetworkDisconnectionException If framework is still trying to recover from
     * a possibly transient loss of network
     */
    public String getRemoteMediaUrl() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        if (remoteMediaPlayer != null && remoteMediaPlayer.getMediaInfo() != null) {
            MediaInfo info = remoteMediaPlayer.getMediaInfo();
            remoteMediaPlayer.getMediaStatus().getPlayerState();
            return info.getContentId();
        }
        throw new NoConnectionException();
    }

    /**
     * Indicates if the remote media is currently playing (or buffering).
     *
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public boolean isRemoteMediaPlaying() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        return state == MediaStatus.PLAYER_STATE_BUFFERING || state == MediaStatus.PLAYER_STATE_PLAYING;
    }

    /**
     * Returns <code>true</code> if the remote connected device is playing a movie.
     *
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public boolean isRemoteMediaPaused() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        return state == MediaStatus.PLAYER_STATE_PAUSED;
    }

    /**
     * Returns <code>true</code> only if there is a media on the remote being played, paused or
     * buffered.
     *
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public boolean isRemoteMediaLoaded() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        return isRemoteMediaPaused() || isRemoteMediaPlaying();
    }

    /**
     * Returns the {@link MediaInfo} for the current media
     *
     * @throws NoConnectionException If no connectivity to the device exists
     * @throws TransientNetworkDisconnectionException If framework is still trying to recover from
     * a possibly transient loss of network
     */
    public MediaInfo getRemoteMediaInformation()
            throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        checkRemoteMediaPlayerAvailable();
        return remoteMediaPlayer.getMediaInfo();
    }

    /**
     * Gets the remote's system volume. It internally detects what type of volume is used.
     *
     * @throws NoConnectionException If no connectivity to the device exists
     * @throws TransientNetworkDisconnectionException If framework is still trying to recover from
     * a possibly transient loss of network
     */
    public double getStreamVolume() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        checkRemoteMediaPlayerAvailable();
        return remoteMediaPlayer.getMediaStatus().getStreamVolume();
    }

    /**
     * Sets the stream volume.
     *
     * @param volume Should be a value between 0 and 1, inclusive.
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     * @throws CastException If setting system volume fails
     */
    public void setStreamVolume(double volume)
            throws CastException, TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        if (volume > 1.0) {
            volume = 1.0;
        } else if (volume < 0) {
            volume = 0.0;
        }

        RemoteMediaPlayer mediaPlayer = getRemoteMediaPlayer();
        if (mediaPlayer == null) {
            throw new NoConnectionException();
        }
        mediaPlayer.setStreamVolume(mApiClient, volume).setResultCallback((result) -> {
            if (!result.getStatus().isSuccess()) {
                onFailed(R.string.cast_failed_setting_volume, result.getStatus().getStatusCode());
            } else {
                CastManager.this.onStreamVolumeChanged();
            }
        });
    }

    /**
     * Returns <code>true</code> if remote Stream is muted.
     *
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public boolean isStreamMute() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        checkRemoteMediaPlayerAvailable();
        return remoteMediaPlayer.getMediaStatus().isMute();
    }

    /**
     * Returns <code>true</code> if remote device is muted.
     *
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public boolean isMute() throws TransientNetworkDisconnectionException, NoConnectionException {
        return isStreamMute() || isDeviceMute();
    }

    /**
     * Mutes or un-mutes the stream volume.
     *
     * @throws CastException
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void setStreamMute(boolean mute)
            throws CastException, TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        checkRemoteMediaPlayerAvailable();
        remoteMediaPlayer.setStreamMute(mApiClient, mute);
    }

    /**
     * Returns the duration of the media that is loaded, in milliseconds.
     *
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public long getMediaDuration() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        checkRemoteMediaPlayerAvailable();
        return remoteMediaPlayer.getStreamDuration();
    }

    /**
     * Returns the time left (in milliseconds) of the current media. If there is no
     * {@code RemoteMediaPlayer}, it returns -1.
     *
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public long getMediaTimeRemaining() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            return -1;
        }
        return isRemoteStreamLive() ? liveStreamDuration
                : remoteMediaPlayer.getStreamDuration() - remoteMediaPlayer.getApproximateStreamPosition();
    }

    /**
     * Returns the current (approximate) position of the current media, in milliseconds.
     *
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public long getCurrentMediaPosition() throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        checkRemoteMediaPlayerAvailable();
        return remoteMediaPlayer.getApproximateStreamPosition();
    }

    public int getApplicationStandbyState() throws IllegalStateException {
        Log.d(TAG, "getApplicationStandbyState()");
        return Cast.CastApi.getStandbyState(mApiClient);
    }

    private void onApplicationDisconnected(int errorCode) {
        Log.d(TAG, "onApplicationDisconnected() reached with error code: " + errorCode);
        mApplicationErrorCode = errorCode;
        for (CastConsumer consumer : castConsumers) {
            consumer.onApplicationDisconnected(errorCode);
        }
        if (mMediaRouter != null) {
            Log.d(TAG, "onApplicationDisconnected(): Cached RouteInfo: " + getRouteInfo());
            Log.d(TAG, "onApplicationDisconnected(): Selected RouteInfo: " + mMediaRouter.getSelectedRoute());
            if (getRouteInfo() == null || mMediaRouter.getSelectedRoute().equals(getRouteInfo())) {
                Log.d(TAG, "onApplicationDisconnected(): Setting route to default");
                mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute());
            }
        }
        onDeviceSelected(null /* CastDevice */, null /* RouteInfo */);
    }

    private void onApplicationStatusChanged() {
        if (!isConnected()) {
            return;
        }
        try {
            String appStatus = Cast.CastApi.getApplicationStatus(mApiClient);
            Log.d(TAG, "onApplicationStatusChanged() reached: " + appStatus);
            for (CastConsumer consumer : castConsumers) {
                consumer.onApplicationStatusChanged(appStatus);
            }
        } catch (IllegalStateException e) {
            Log.e(TAG, "onApplicationStatusChanged()", e);
        }
    }

    private void onDeviceVolumeChanged() {
        Log.d(TAG, "onDeviceVolumeChanged() reached");
        double volume;
        try {
            volume = getDeviceVolume();
            boolean isMute = isDeviceMute();
            for (CastConsumer consumer : castConsumers) {
                consumer.onVolumeChanged(volume, isMute);
            }
        } catch (TransientNetworkDisconnectionException | NoConnectionException e) {
            Log.e(TAG, "Failed to get volume", e);
        }

    }

    private void onStreamVolumeChanged() {
        Log.d(TAG, "onStreamVolumeChanged() reached");
        double volume;
        try {
            volume = getStreamVolume();
            boolean isMute = isStreamMute();
            for (CastConsumer consumer : castConsumers) {
                consumer.onStreamVolumeChanged(volume, isMute);
            }
        } catch (TransientNetworkDisconnectionException | NoConnectionException e) {
            Log.e(TAG, "Failed to get volume", e);
        }
    }

    @Override
    protected void onApplicationConnected(ApplicationMetadata appMetadata, String applicationStatus,
            String sessionId, boolean wasLaunched) {
        Log.d(TAG, "onApplicationConnected() reached with sessionId: " + sessionId + ", and mReconnectionStatus="
                + mReconnectionStatus);
        mApplicationErrorCode = NO_APPLICATION_ERROR;
        if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) {
            // we have tried to reconnect and successfully launched the app, so
            // it is time to select the route and make the cast icon happy :-)
            List<MediaRouter.RouteInfo> routes = mMediaRouter.getRoutes();
            if (routes != null) {
                String routeId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_ROUTE_ID);
                for (MediaRouter.RouteInfo routeInfo : routes) {
                    if (routeId.equals(routeInfo.getId())) {
                        // found the right route
                        Log.d(TAG, "Found the correct route during reconnection attempt");
                        mReconnectionStatus = RECONNECTION_STATUS_FINALIZED;
                        mMediaRouter.selectRoute(routeInfo);
                        break;
                    }
                }
            }
        }
        try {
            //attachDataChannel();
            attachMediaChannel();
            mSessionId = sessionId;
            // saving device for future retrieval; we only save the last session info
            mPreferenceAccessor.saveStringToPreference(PREFS_KEY_SESSION_ID, mSessionId);
            remoteMediaPlayer.requestStatus(mApiClient).setResultCallback(result -> {
                if (!result.getStatus().isSuccess()) {
                    onFailed(R.string.cast_failed_status_request, result.getStatus().getStatusCode());
                }
            });
            for (CastConsumer consumer : castConsumers) {
                consumer.onApplicationConnected(appMetadata, mSessionId, wasLaunched);
            }
        } catch (TransientNetworkDisconnectionException e) {
            Log.e(TAG, "Failed to attach media/data channel due to network issues", e);
            onFailed(R.string.cast_failed_no_connection_trans, NO_STATUS_CODE);
        } catch (NoConnectionException e) {
            Log.e(TAG, "Failed to attach media/data channel due to network issues", e);
            onFailed(R.string.cast_failed_no_connection, NO_STATUS_CODE);
        }
    }

    /*
     * (non-Javadoc)
     * @see com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager
     * #onConnectivityRecovered()
     */
    @Override
    public void onConnectivityRecovered() {
        reattachMediaChannel();
        //reattachDataChannel();
        super.onConnectivityRecovered();
    }

    /*
     * (non-Javadoc)
     * @see com.google.android.gms.cast.CastClient.Listener#onApplicationStopFailed (int)
     */
    @Override
    public void onApplicationStopFailed(int errorCode) {
        for (CastConsumer consumer : castConsumers) {
            consumer.onApplicationStopFailed(errorCode);
        }
    }

    @Override
    public void onApplicationConnectionFailed(int errorCode) {
        Log.d(TAG, "onApplicationConnectionFailed() reached with errorCode: " + errorCode);
        mApplicationErrorCode = errorCode;
        if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) {
            if (errorCode == CastStatusCodes.APPLICATION_NOT_RUNNING) {
                // while trying to re-establish session, we found out that the app is not running
                // so we need to disconnect
                mReconnectionStatus = RECONNECTION_STATUS_INACTIVE;
                onDeviceSelected(null /* CastDevice */, null /* RouteInfo */);
            }
        } else {
            for (CastConsumer consumer : castConsumers) {
                consumer.onApplicationConnectionFailed(errorCode);
            }
            onDeviceSelected(null /* CastDevice */, null /* RouteInfo */);
            if (mMediaRouter != null) {
                Log.d(TAG, "onApplicationConnectionFailed(): Setting route to default");
                mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute());
            }
        }
    }

    /**
     * Loads a media. For this to succeed, you need to have successfully launched the application.
     *
     * @param media The media to be loaded
     * @param autoPlay If <code>true</code>, playback starts after load
     * @param position Where to start the playback (only used if autoPlay is <code>true</code>.
     * Units is milliseconds.
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void loadMedia(MediaInfo media, boolean autoPlay, int position)
            throws TransientNetworkDisconnectionException, NoConnectionException {
        loadMedia(media, autoPlay, position, null);
    }

    /**
     * Loads a media. For this to succeed, you need to have successfully launched the application.
     *
     * @param media The media to be loaded
     * @param autoPlay If <code>true</code>, playback starts after load
     * @param position Where to start the playback (only used if autoPlay is <code>true</code>).
     * Units is milliseconds.
     * @param customData Optional {@link JSONObject} data to be passed to the cast device
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void loadMedia(MediaInfo media, boolean autoPlay, int position, JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException {
        loadMedia(media, null, autoPlay, position, customData);
    }

    /**
     * Loads a media. For this to succeed, you need to have successfully launched the application.
     *
     * @param media The media to be loaded
     * @param activeTracks An array containing the list of track IDs to be set active for this
     * media upon a successful load
     * @param autoPlay If <code>true</code>, playback starts after load
     * @param position Where to start the playback (only used if autoPlay is <code>true</code>).
     * Units is milliseconds.
     * @param customData Optional {@link JSONObject} data to be passed to the cast device
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void loadMedia(MediaInfo media, final long[] activeTracks, boolean autoPlay, int position,
            JSONObject customData) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "loadMedia");
        checkConnectivity();
        if (media == null) {
            return;
        }
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to load a video with no active media session");
            throw new NoConnectionException();
        }

        Log.d(TAG, "remoteMediaPlayer.load() with media=" + media.getMetadata().getString(MediaMetadata.KEY_TITLE)
                + ", position=" + position + ", autoplay=" + autoPlay);
        remoteMediaPlayer.load(mApiClient, media, autoPlay, position, activeTracks, customData)
                .setResultCallback(result -> {
                    for (CastConsumer consumer : castConsumers) {
                        consumer.onMediaLoadResult(result.getStatus().getStatusCode());
                    }
                });
    }

    /**
     * Loads and optionally starts playback of a new queue of media items.
     *
     * @param items Array of items to load, in the order that they should be played. Must not be
     *              {@code null} or empty.
     * @param startIndex The array index of the item in the {@code items} array that should be
     *                   played first (i.e., it will become the currentItem).If {@code repeatMode}
     *                   is {@link MediaStatus#REPEAT_MODE_REPEAT_OFF} playback will end when the
     *                   last item in the array is played.
     *                   <p>
     *                   This may be useful for continuation scenarios where the user was already
     *                   using the sender application and in the middle decides to cast. This lets
     *                   the sender application avoid mapping between the local and remote queue
     *                   positions and/or avoid issuing an extra request to update the queue.
     *                   <p>
     *                   This value must be less than the length of {@code items}.
     * @param repeatMode The repeat playback mode for the queue. One of
     *                   {@link MediaStatus#REPEAT_MODE_REPEAT_OFF},
     *                   {@link MediaStatus#REPEAT_MODE_REPEAT_ALL},
     *                   {@link MediaStatus#REPEAT_MODE_REPEAT_SINGLE} and
     *                   {@link MediaStatus#REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE}.
     * @param customData Custom application-specific data to pass along with the request, may be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void queueLoad(final MediaQueueItem[] items, final int startIndex, final int repeatMode,
            final JSONObject customData) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "queueLoad");
        checkConnectivity();
        if (items == null || items.length == 0) {
            return;
        }
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to queue one or more videos with no active media session");
            throw new NoConnectionException();
        }
        Log.d(TAG, "remoteMediaPlayer.queueLoad() with " + items.length + "items, starting at " + startIndex);
        remoteMediaPlayer.queueLoad(mApiClient, items, startIndex, repeatMode, customData)
                .setResultCallback(result -> {
                    for (CastConsumer consumer : castConsumers) {
                        consumer.onMediaQueueOperationResult(QUEUE_OPERATION_LOAD,
                                result.getStatus().getStatusCode());
                    }
                });
    }

    /**
     * Inserts a list of new media items into the queue.
     *
     * @param itemsToInsert List of items to insert into the queue, in the order that they should be
     *                      played. The itemId field of the items should be unassigned or the
     *                      request will fail with an INVALID_PARAMS error. Must not be {@code null}
     *                      or empty.
     * @param insertBeforeItemId ID of the item that will be located immediately after the inserted
     *                           list. If the value is {@link MediaQueueItem#INVALID_ITEM_ID} or
     *                           invalid, the inserted list will be appended to the end of the
     *                           queue.
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     * @throws IllegalArgumentException
     */
    public void queueInsertItems(final MediaQueueItem[] itemsToInsert, final int insertBeforeItemId,
            final JSONObject customData) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "queueInsertItems");
        checkConnectivity();
        if (itemsToInsert == null || itemsToInsert.length == 0) {
            throw new IllegalArgumentException("items cannot be empty or null");
        }
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to insert into queue with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueInsertItems(mApiClient, itemsToInsert, insertBeforeItemId, customData)
                .setResultCallback(result -> {
                    for (CastConsumer consumer : castConsumers) {
                        consumer.onMediaQueueOperationResult(QUEUE_OPERATION_INSERT_ITEMS,
                                result.getStatus().getStatusCode());
                    }
                });
    }

    /**
     * Updates properties of a subset of the existing items in the media queue.
     *
     * @param itemsToUpdate List of queue items to be updated. The items will retain the existing
     *                      order and will be fully replaced with the ones provided, including the
     *                      media information. Any other items currently in the queue will remain
     *                      unchanged. The tracks information can not change once the item is loaded
     *                      (if the item is the currentItem). If any of the items does not exist it
     *                      will be ignored.
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void queueUpdateItems(final MediaQueueItem[] itemsToUpdate, final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to update the queue with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueUpdateItems(mApiClient, itemsToUpdate, customData).setResultCallback(result -> {
            Log.d(TAG, "queueUpdateItems() " + result.getStatus() + result.getStatus().isSuccess());
            for (CastConsumer consumer : castConsumers) {
                consumer.onMediaQueueOperationResult(QUEUE_OPERATION_UPDATE_ITEMS,
                        result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Plays the item with {@code itemId} in the queue.
     * <p>
     * If {@code itemId} is not found in the queue, this method will report success without sending
     * a request to the receiver.
     *
     * @param itemId The ID of the item to which to jump.
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     * @throws IllegalArgumentException
     */
    public void queueJumpToItem(int itemId, final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException, IllegalArgumentException {
        checkConnectivity();
        if (itemId == MediaQueueItem.INVALID_ITEM_ID) {
            throw new IllegalArgumentException("itemId is not valid");
        }
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to jump in a queue with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueJumpToItem(mApiClient, itemId, customData).setResultCallback(result -> {
            for (CastConsumer consumer : castConsumers) {
                consumer.onMediaQueueOperationResult(QUEUE_OPERATION_JUMP, result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Removes a list of items from the queue. If the remaining queue is empty, the media session
     * will be terminated.
     *
     * @param itemIdsToRemove The list of media item IDs to remove. Must not be {@code null} or
     *                        empty.
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     * @throws IllegalArgumentException
     */
    public void queueRemoveItems(final int[] itemIdsToRemove, final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException, IllegalArgumentException {
        Log.d(TAG, "queueRemoveItems");
        checkConnectivity();
        if (itemIdsToRemove == null || itemIdsToRemove.length == 0) {
            throw new IllegalArgumentException("itemIds cannot be empty or null");
        }
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to remove items from queue with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueRemoveItems(mApiClient, itemIdsToRemove, customData).setResultCallback(result -> {
            for (CastConsumer consumer : castConsumers) {
                consumer.onMediaQueueOperationResult(QUEUE_OPERATION_REMOVE_ITEMS,
                        result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Removes the item with {@code itemId} from the queue.
     * <p>
     * If {@code itemId} is not found in the queue, this method will silently return without sending
     * a request to the receiver. A {@code itemId} may not be in the queue because it wasn't
     * originally in the queue, or it was removed by another sender.
     *
     * @param itemId The ID of the item to be removed.
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     * @throws IllegalArgumentException
     */
    public void queueRemoveItem(final int itemId, final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException, IllegalArgumentException {
        Log.d(TAG, "queueRemoveItem");
        checkConnectivity();
        if (itemId == MediaQueueItem.INVALID_ITEM_ID) {
            throw new IllegalArgumentException("itemId is invalid");
        }
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to remove an item from queue with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueRemoveItem(mApiClient, itemId, customData).setResultCallback(result -> {
            for (CastConsumer consumer : castConsumers) {
                consumer.onMediaQueueOperationResult(QUEUE_OPERATION_REMOVE_ITEM,
                        result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Reorder a list of media items in the queue.
     *
     * @param itemIdsToReorder The list of media item IDs to reorder, in the new order. Any other
     *                         items currently in the queue will maintain their existing order. The
     *                         list will be inserted just before the item specified by
     *                         {@code insertBeforeItemId}, or at the end of the queue if
     *                         {@code insertBeforeItemId} is {@link MediaQueueItem#INVALID_ITEM_ID}.
     *                         <p>
     *                         For example:
     *                         <p>
     *                         If insertBeforeItemId is not specified <br>
     *                         Existing queue: "A","D","G","H","B","E" <br>
     *                         itemIds: "D","H","B" <br>
     *                         New Order: "A","G","E","D","H","B" <br>
     *                         <p>
     *                         If insertBeforeItemId is "A" <br>
     *                         Existing queue: "A","D","G","H","B" <br>
     *                         itemIds: "D","H","B" <br>
     *                         New Order: "D","H","B","A","G","E" <br>
     *                         <p>
     *                         If insertBeforeItemId is "G" <br>
     *                         Existing queue: "A","D","G","H","B" <br>
     *                         itemIds: "D","H","B" <br>
     *                         New Order: "A","D","H","B","G","E" <br>
     *                         <p>
     *                         If any of the items does not exist it will be ignored.
     *                         Must not be {@code null} or empty.
     * @param insertBeforeItemId ID of the item that will be located immediately after the reordered
     *                           list. If set to {@link MediaQueueItem#INVALID_ITEM_ID}, the
     *                           reordered list will be appended at the end of the queue.
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void queueReorderItems(final int[] itemIdsToReorder, final int insertBeforeItemId,
            final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException, IllegalArgumentException {
        Log.d(TAG, "queueReorderItems");
        checkConnectivity();
        if (itemIdsToReorder == null || itemIdsToReorder.length == 0) {
            throw new IllegalArgumentException("itemIdsToReorder cannot be empty or null");
        }
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to reorder items in a queue with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueReorderItems(mApiClient, itemIdsToReorder, insertBeforeItemId, customData)
                .setResultCallback(result -> {
                    for (CastConsumer consumer : castConsumers) {
                        consumer.onMediaQueueOperationResult(QUEUE_OPERATION_REORDER,
                                result.getStatus().getStatusCode());
                    }
                });
    }

    /**
     * Moves the item with {@code itemId} to a new position in the queue.
     * <p>
     * If {@code itemId} is not found in the queue, either because it wasn't there originally or it
     * was removed by another sender before calling this function, this function will silently
     * return without sending a request to the receiver.
     *
     * @param itemId The ID of the item to be moved.
     * @param newIndex The new index of the item. If the value is negative, an error will be
     *                 returned. If the value is out of bounds, or becomes out of bounds because the
     *                 queue was shortened by another sender while this request is in progress, the
     *                 item will be moved to the end of the queue.
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void queueMoveItemToNewIndex(int itemId, int newIndex, final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "queueMoveItemToNewIndex");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to mote item to new index with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueMoveItemToNewIndex(mApiClient, itemId, newIndex, customData)
                .setResultCallback(result -> {
                    for (CastConsumer consumer : castConsumers) {
                        consumer.onMediaQueueOperationResult(QUEUE_OPERATION_MOVE,
                                result.getStatus().getStatusCode());
                    }
                });
    }

    /**
     * Appends a new media item to the end of the queue.
     *
     * @param item The item to append. Must not be {@code null}.
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void queueAppendItem(MediaQueueItem item, final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "queueAppendItem");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to append item with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueAppendItem(mApiClient, item, customData).setResultCallback(result -> {
            for (CastConsumer consumer : castConsumers) {
                consumer.onMediaQueueOperationResult(QUEUE_OPERATION_APPEND, result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Jumps to the next item in the queue.
     *
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void queueNext(final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "queueNext");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to update the queue with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueNext(mApiClient, customData).setResultCallback(result -> {
            for (CastConsumer consumer : castConsumers) {
                consumer.onMediaQueueOperationResult(QUEUE_OPERATION_NEXT, result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Jumps to the previous item in the queue.
     *
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void queuePrev(final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "queuePrev");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to update the queue with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queuePrev(mApiClient, customData).setResultCallback(result -> {
            for (CastConsumer consumer : castConsumers) {
                consumer.onMediaQueueOperationResult(QUEUE_OPERATION_PREV, result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Inserts an item in the queue and starts the playback of that newly inserted item. It is
     * assumed that we are inserting  before the "current item"
     *
     * @param item The item to be inserted
     * @param insertBeforeItemId ID of the item that will be located immediately after the inserted
     * and is assumed to be the "current item"
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     * @throws IllegalArgumentException
     */
    public void queueInsertBeforeCurrentAndPlay(MediaQueueItem item, int insertBeforeItemId,
            final JSONObject customData) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "queueInsertBeforeCurrentAndPlay");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to insert into queue with no active media session");
            throw new NoConnectionException();
        }
        if (item == null || insertBeforeItemId == MediaQueueItem.INVALID_ITEM_ID) {
            throw new IllegalArgumentException("item cannot be empty or insertBeforeItemId cannot be invalid");
        }
        remoteMediaPlayer
                .queueInsertItems(mApiClient, new MediaQueueItem[] { item }, insertBeforeItemId, customData)
                .setResultCallback(result -> {
                    if (result.getStatus().isSuccess()) {

                        try {
                            queuePrev(customData);
                        } catch (TransientNetworkDisconnectionException | NoConnectionException e) {
                            Log.e(TAG, "queuePrev() Failed to skip to previous", e);
                        }
                    }
                    for (CastConsumer consumer : castConsumers) {
                        consumer.onMediaQueueOperationResult(QUEUE_OPERATION_INSERT_ITEMS,
                                result.getStatus().getStatusCode());
                    }
                });
    }

    /**
     * Sets the repeat mode of the queue.
     *
     * @param repeatMode The repeat playback mode for the queue.
     * @param customData Custom application-specific data to pass along with the request. May be
     *                   {@code null}.
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void queueSetRepeatMode(final int repeatMode, final JSONObject customData)
            throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "queueSetRepeatMode");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to update the queue with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.queueSetRepeatMode(mApiClient, repeatMode, customData).setResultCallback(result -> {
            if (!result.getStatus().isSuccess()) {
                Log.d(TAG, "Failed with status: " + result.getStatus());
            }
            for (CastConsumer consumer : castConsumers) {
                consumer.onMediaQueueOperationResult(QUEUE_OPERATION_SET_REPEAT,
                        result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Plays the loaded media.
     *
     * @param position Where to start the playback. Units is milliseconds.
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void play(int position) throws TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        Log.d(TAG, "attempting to play media at position " + position + " seconds");
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to play a video with no active media session");
            throw new NoConnectionException();
        }
        seekAndPlay(position);
    }

    /**
     * Resumes the playback from where it was left (can be the beginning).
     *
     * @param customData Optional {@link JSONObject} data to be passed to the cast device
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void play(JSONObject customData) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "play(customData)");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to play a video with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.play(mApiClient, customData).setResultCallback(result -> {
            if (!result.getStatus().isSuccess()) {
                onFailed(R.string.cast_failed_to_play, result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Resumes the playback from where it was left (can be the beginning).
     *
     * @throws CastException
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void play() throws CastException, TransientNetworkDisconnectionException, NoConnectionException {
        play(null);
    }

    /**
     * Stops the playback of media/stream
     *
     * @param customData Optional {@link JSONObject}
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void stop(JSONObject customData) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "stop()");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to stop a stream with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.stop(mApiClient, customData).setResultCallback(result -> {
            if (!result.getStatus().isSuccess()) {
                onFailed(R.string.cast_failed_to_stop, result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Stops the playback of media/stream
     *
     * @throws CastException
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void stop() throws CastException, TransientNetworkDisconnectionException, NoConnectionException {
        stop(null);
    }

    /**
     * Pauses the playback.
     *
     * @throws CastException
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void pause() throws CastException, TransientNetworkDisconnectionException, NoConnectionException {
        pause(null);
    }

    /**
     * Pauses the playback.
     *
     * @param customData Optional {@link JSONObject} data to be passed to the cast device
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void pause(JSONObject customData) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "attempting to pause media");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to pause a video with no active media session");
            throw new NoConnectionException();
        }
        remoteMediaPlayer.pause(mApiClient, customData).setResultCallback(result -> {
            if (!result.getStatus().isSuccess()) {
                onFailed(R.string.cast_failed_to_pause, result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Seeks to the given point without changing the state of the player, i.e. after seek is
     * completed, it resumes what it was doing before the start of seek.
     *
     * @param position in milliseconds
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void seek(int position) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "attempting to seek media");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to seek a video with no active media session");
            throw new NoConnectionException();
        }
        Log.d(TAG, "remoteMediaPlayer.seek() to position " + position);
        remoteMediaPlayer.seek(mApiClient, position, RESUME_STATE_UNCHANGED).setResultCallback(result -> {
            if (!result.getStatus().isSuccess()) {
                onFailed(R.string.cast_failed_seek, result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Fast forwards the media by the given amount. If {@code lengthInMillis} is negative, it
     * rewinds the media.
     *
     * @param lengthInMillis The amount to fast forward the media, given in milliseconds
     * @throws TransientNetworkDisconnectionException
     * @throws NoConnectionException
     */
    public void forward(int lengthInMillis) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "forward(): attempting to forward media by " + lengthInMillis);
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to seek a video with no active media session");
            throw new NoConnectionException();
        }
        long position = remoteMediaPlayer.getApproximateStreamPosition() + lengthInMillis;
        seek((int) position);
    }

    /**
     * Seeks to the given point and starts playback regardless of the starting state.
     *
     * @param position in milliseconds
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void seekAndPlay(int position) throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "attempting to seek media");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            Log.e(TAG, "Trying to seekAndPlay a video with no active media session");
            throw new NoConnectionException();
        }
        Log.d(TAG, "remoteMediaPlayer.seek() to position " + position + "and play");
        remoteMediaPlayer.seek(mApiClient, position, RESUME_STATE_PLAY).setResultCallback(result -> {
            if (!result.getStatus().isSuccess()) {
                onFailed(R.string.cast_failed_seek, result.getStatus().getStatusCode());
            }
        });
    }

    /**
     * Toggles the playback of the media.
     *
     * @throws CastException
     * @throws NoConnectionException
     * @throws TransientNetworkDisconnectionException
     */
    public void togglePlayback()
            throws CastException, TransientNetworkDisconnectionException, NoConnectionException {
        checkConnectivity();
        boolean isPlaying = isRemoteMediaPlaying();
        if (isPlaying) {
            pause();
        } else {
            if (state == MediaStatus.PLAYER_STATE_IDLE && idleReason == MediaStatus.IDLE_REASON_FINISHED) {
                loadMedia(getRemoteMediaInformation(), true, 0);
            } else {
                play();
            }
        }
    }

    private void attachMediaChannel() throws TransientNetworkDisconnectionException, NoConnectionException {
        Log.d(TAG, "attachMediaChannel()");
        checkConnectivity();
        if (remoteMediaPlayer == null) {
            remoteMediaPlayer = new RemoteMediaPlayer();

            remoteMediaPlayer.setOnStatusUpdatedListener(() -> {
                Log.d(TAG, "RemoteMediaPlayer::onStatusUpdated() is reached");
                CastManager.this.onRemoteMediaPlayerStatusUpdated();
            });

            remoteMediaPlayer.setOnPreloadStatusUpdatedListener(() -> {
                Log.d(TAG, "RemoteMediaPlayer::onPreloadStatusUpdated() is reached");
                CastManager.this.onRemoteMediaPreloadStatusUpdated();
            });

            remoteMediaPlayer.setOnMetadataUpdatedListener(() -> {
                Log.d(TAG, "RemoteMediaPlayer::onMetadataUpdated() is reached");
                CastManager.this.onRemoteMediaPlayerMetadataUpdated();
            });

            remoteMediaPlayer.setOnQueueStatusUpdatedListener(() -> {
                Log.d(TAG, "RemoteMediaPlayer::onQueueStatusUpdated() is reached");
                mediaStatus = remoteMediaPlayer.getMediaStatus();
                if (mediaStatus != null && mediaStatus.getQueueItems() != null) {
                    List<MediaQueueItem> queueItems = mediaStatus.getQueueItems();
                    int itemId = mediaStatus.getCurrentItemId();
                    MediaQueueItem item = mediaStatus.getQueueItemById(itemId);
                    int repeatMode = mediaStatus.getQueueRepeatMode();
                    onQueueUpdated(queueItems, item, repeatMode, false);
                } else {
                    onQueueUpdated(null, null, MediaStatus.REPEAT_MODE_REPEAT_OFF, false);
                }
            });

        }
        try {
            Log.d(TAG, "Registering MediaChannel namespace");
            Cast.CastApi.setMessageReceivedCallbacks(mApiClient, remoteMediaPlayer.getNamespace(),
                    remoteMediaPlayer);
        } catch (IOException | IllegalStateException e) {
            Log.e(TAG, "attachMediaChannel()", e);
        }
    }

    private void reattachMediaChannel() {
        if (remoteMediaPlayer != null && mApiClient != null) {
            try {
                Log.d(TAG, "Registering MediaChannel namespace");
                Cast.CastApi.setMessageReceivedCallbacks(mApiClient, remoteMediaPlayer.getNamespace(),
                        remoteMediaPlayer);
            } catch (IOException | IllegalStateException e) {
                Log.e(TAG, "reattachMediaChannel()", e);
            }
        }
    }

    private void detachMediaChannel() {
        Log.d(TAG, "trying to detach media channel");
        if (remoteMediaPlayer != null) {
            try {
                Cast.CastApi.removeMessageReceivedCallbacks(mApiClient, remoteMediaPlayer.getNamespace());
            } catch (IOException | IllegalStateException e) {
                Log.e(TAG, "detachMediaChannel()", e);
            }
            remoteMediaPlayer = null;
        }
    }

    /**
     * Returns the playback status of the remote device.
     *
     * @return Returns one of the values
     * <ul>
     * <li> <code>MediaStatus.PLAYER_STATE_UNKNOWN</code></li>
     * <li> <code>MediaStatus.PLAYER_STATE_IDLE</code></li>
     * <li> <code>MediaStatus.PLAYER_STATE_PLAYING</code></li>
     * <li> <code>MediaStatus.PLAYER_STATE_PAUSED</code></li>
     * <li> <code>MediaStatus.PLAYER_STATE_BUFFERING</code></li>
     * </ul>
     */
    public int getPlaybackStatus() {
        return state;
    }

    /**
     * Returns the latest retrieved value for the {@link MediaStatus}. This value is updated
     * whenever the onStatusUpdated callback is called.
     */
    public final MediaStatus getMediaStatus() {
        return mediaStatus;
    }

    /**
     * Returns the Idle reason, defined in <code>MediaStatus.IDLE_*</code>. Note that the returned
     * value is only meaningful if the status is truly <code>MediaStatus.PLAYER_STATE_IDLE
     * </code>
     *
     * <p>Possible values are:
     * <ul>
     *     <li>IDLE_REASON_NONE</li>
     *     <li>IDLE_REASON_FINISHED</li>
     *     <li>IDLE_REASON_CANCELED</li>
     *     <li>IDLE_REASON_INTERRUPTED</li>
     *     <li>IDLE_REASON_ERROR</li>
     * </ul>
     */
    public int getIdleReason() {
        return idleReason;
    }

    private void onMessageSendFailed(int errorCode) {
        for (CastConsumer consumer : castConsumers) {
            consumer.onDataMessageSendFailed(errorCode);
        }
    }

    /*
     * This is called by onStatusUpdated() of the RemoteMediaPlayer
     */
    private void onRemoteMediaPlayerStatusUpdated() {
        Log.d(TAG, "onRemoteMediaPlayerStatusUpdated() reached");
        if (mApiClient == null || remoteMediaPlayer == null) {
            Log.d(TAG, "mApiClient or remoteMediaPlayer is null, so will not proceed");
            return;
        }
        mediaStatus = remoteMediaPlayer.getMediaStatus();
        if (mediaStatus == null) {
            Log.d(TAG, "MediaStatus is null, so will not proceed");
            return;
        } else {
            List<MediaQueueItem> queueItems = mediaStatus.getQueueItems();
            if (queueItems != null) {
                int itemId = mediaStatus.getCurrentItemId();
                MediaQueueItem item = mediaStatus.getQueueItemById(itemId);
                int repeatMode = mediaStatus.getQueueRepeatMode();
                onQueueUpdated(queueItems, item, repeatMode, false);
            } else {
                onQueueUpdated(null, null, MediaStatus.REPEAT_MODE_REPEAT_OFF, false);
            }
            state = mediaStatus.getPlayerState();
            idleReason = mediaStatus.getIdleReason();

            if (state == MediaStatus.PLAYER_STATE_PLAYING) {
                Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = playing");
            } else if (state == MediaStatus.PLAYER_STATE_PAUSED) {
                Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = paused");
            } else if (state == MediaStatus.PLAYER_STATE_IDLE) {
                Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = IDLE with reason: " + idleReason);
                if (idleReason == MediaStatus.IDLE_REASON_ERROR) {
                    // something bad happened on the cast device
                    Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): IDLE reason = ERROR");
                    onFailed(R.string.cast_failed_receiver_player_error, NO_STATUS_CODE);
                }
            } else if (state == MediaStatus.PLAYER_STATE_BUFFERING) {
                Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = buffering");
            } else {
                Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = unknown");
            }
        }
        for (CastConsumer consumer : castConsumers) {
            consumer.onRemoteMediaPlayerStatusUpdated();
        }
        if (mediaStatus != null) {
            double volume = mediaStatus.getStreamVolume();
            boolean isMute = mediaStatus.isMute();
            for (CastConsumer consumer : castConsumers) {
                consumer.onStreamVolumeChanged(volume, isMute);
            }
        }
    }

    private void onRemoteMediaPreloadStatusUpdated() {
        MediaQueueItem item = null;
        mediaStatus = remoteMediaPlayer.getMediaStatus();
        if (mediaStatus != null) {
            item = mediaStatus.getQueueItemById(mediaStatus.getPreloadedItemId());
        }
        preLoadingItem = item;
        Log.d(TAG, "onRemoteMediaPreloadStatusUpdated() " + item);
        for (CastConsumer consumer : castConsumers) {
            consumer.onRemoteMediaPreloadStatusUpdated(item);
        }
    }

    public MediaQueueItem getPreLoadingItem() {
        return preLoadingItem;
    }

    /*
    * This is called by onQueueStatusUpdated() of RemoteMediaPlayer
    */
    private void onQueueUpdated(List<MediaQueueItem> queueItems, MediaQueueItem item, int repeatMode,
            boolean shuffle) {
        Log.d(TAG, "onQueueUpdated() reached");
        Log.d(TAG, String.format("Queue Items size: %d, Item: %s, Repeat Mode: %d, Shuffle: %s",
                queueItems == null ? 0 : queueItems.size(), item, repeatMode, shuffle));
        if (queueItems != null) {
            mediaQueue = new MediaQueue(new CopyOnWriteArrayList<>(queueItems), item, shuffle, repeatMode);
        } else {
            mediaQueue = new MediaQueue(new CopyOnWriteArrayList<>(), null, false,
                    MediaStatus.REPEAT_MODE_REPEAT_OFF);
        }
        for (CastConsumer consumer : castConsumers) {
            consumer.onMediaQueueUpdated(queueItems, item, repeatMode, shuffle);
        }
    }

    /*
     * This is called by onMetadataUpdated() of RemoteMediaPlayer
     */
    public void onRemoteMediaPlayerMetadataUpdated() {
        Log.d(TAG, "onRemoteMediaPlayerMetadataUpdated() reached");
        for (CastConsumer consumer : castConsumers) {
            consumer.onRemoteMediaPlayerMetadataUpdated();
        }
    }

    /**
     * Registers a {@link CastConsumer} interface with this class.
     * Registered listeners will be notified of changes to a variety of
     * lifecycle and media status changes through the callbacks that the interface provides.
     *
     * @see DefaultCastConsumer
     */
    public synchronized void addCastConsumer(CastConsumer listener) {
        if (listener != null) {
            addBaseCastConsumer(listener);
            castConsumers.add(listener);
            Log.d(TAG, "Successfully added the new CastConsumer listener " + listener);
        }
    }

    /**
     * Unregisters a {@link CastConsumer}.
     */
    public synchronized void removeCastConsumer(CastConsumer listener) {
        if (listener != null) {
            removeBaseCastConsumer(listener);
            castConsumers.remove(listener);
        }
    }

    @Override
    protected void onDeviceUnselected() {
        detachMediaChannel();
        //removeDataChannel();
        state = MediaStatus.PLAYER_STATE_IDLE;
        mediaStatus = null;
    }

    @Override
    protected Cast.CastOptions.Builder getCastOptionBuilder(CastDevice device) {
        Cast.CastOptions.Builder builder = new Cast.CastOptions.Builder(mSelectedCastDevice, new CastListener());
        if (isFeatureEnabled(CastConfiguration.FEATURE_DEBUGGING)) {
            builder.setVerboseLoggingEnabled(true);
        }
        return builder;
    }

    @Override
    public void onConnectionFailed(ConnectionResult result) {
        super.onConnectionFailed(result);
        state = MediaStatus.PLAYER_STATE_IDLE;
        mediaStatus = null;
    }

    @Override
    public void onDisconnected(boolean stopAppOnExit, boolean clearPersistedConnectionData,
            boolean setDefaultRoute) {
        super.onDisconnected(stopAppOnExit, clearPersistedConnectionData, setDefaultRoute);
        state = MediaStatus.PLAYER_STATE_IDLE;
        mediaStatus = null;
        mediaQueue = null;
    }

    class CastListener extends Cast.Listener {

        /*
         * (non-Javadoc)
         * @see com.google.android.gms.cast.Cast.Listener#onApplicationDisconnected (int)
         */
        @Override
        public void onApplicationDisconnected(int statusCode) {
            CastManager.this.onApplicationDisconnected(statusCode);
        }

        /*
         * (non-Javadoc)
         * @see com.google.android.gms.cast.Cast.Listener#onApplicationStatusChanged ()
         */
        @Override
        public void onApplicationStatusChanged() {
            CastManager.this.onApplicationStatusChanged();
        }

        @Override
        public void onVolumeChanged() {
            CastManager.this.onDeviceVolumeChanged();
        }
    }

    @Override
    public void onFailed(int resourceId, int statusCode) {
        Log.d(TAG, "onFailed: " + mContext.getString(resourceId) + ", code: " + statusCode);
        super.onFailed(resourceId, statusCode);
    }

    /**
     * Clients can call this method to delegate handling of the volume. Clients should override
     * {@code dispatchEvent} and call this method:
     * <pre>
     public boolean dispatchKeyEvent(KeyEvent event) {
     if (mCastManager.onDispatchVolumeKeyEvent(event, VOLUME_DELTA)) {
     return true;
     }
     return super.dispatchKeyEvent(event);
     }
     * </pre>
     * @param event The dispatched event.
     * @param volumeDelta The amount by which volume should be increased or decreased in each step
     * @return <code>true</code> if volume is handled by the library, <code>false</code> otherwise.
     */
    public boolean onDispatchVolumeKeyEvent(KeyEvent event, double volumeDelta) {
        if (isConnected()) {
            boolean isKeyDown = event.getAction() == KeyEvent.ACTION_DOWN;
            switch (event.getKeyCode()) {
            case KeyEvent.KEYCODE_VOLUME_UP:
                return changeVolume(volumeDelta, isKeyDown);
            case KeyEvent.KEYCODE_VOLUME_DOWN:
                return changeVolume(-volumeDelta, isKeyDown);
            }
        }
        return false;
    }

    private boolean changeVolume(double volumeIncrement, boolean isKeyDown) {
        if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
                && getPlaybackStatus() == MediaStatus.PLAYER_STATE_PLAYING
                && isFeatureEnabled(CastConfiguration.FEATURE_LOCKSCREEN)) {
            return false;
        }

        if (isKeyDown) {
            try {
                adjustDeviceVolume(volumeIncrement);
            } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) {
                Log.e(TAG, "Failed to change volume", e);
            }
        }
        return true;
    }

    /**
     * Sets the volume step, i.e. the fraction by which volume will increase or decrease each time
     * user presses the hard volume buttons on the device.
     *
     * @param volumeStep Should be a double between 0 and 1, inclusive.
     */
    public CastManager setVolumeStep(double volumeStep) {
        if ((volumeStep > 1) || (volumeStep < 0)) {
            throw new IllegalArgumentException("Volume Step should be between 0 and 1, inclusive");
        }
        this.volumeStep = volumeStep;
        return this;
    }

    /**
     * Returns the volume step. The default value is {@code DEFAULT_VOLUME_STEP}.
     */
    public double getVolumeStep() {
        return volumeStep;
    }

    public final MediaQueue getMediaQueue() {
        return mediaQueue;
    }

    /**
     * Checks whether the selected Cast Device has the specified audio or video capabilities.
     *
     * @param capability capability from:
     * <ul>
     *     <li>{@link CastDevice#CAPABILITY_AUDIO_IN}</li>
     *     <li>{@link CastDevice#CAPABILITY_AUDIO_OUT}</li>
     *     <li>{@link CastDevice#CAPABILITY_VIDEO_IN}</li>
     *     <li>{@link CastDevice#CAPABILITY_VIDEO_OUT}</li>
     * </ul>
     * @param defaultVal value to return whenever there's no device selected.
     * @return {@code true} if the selected device has the specified capability,
     * {@code false} otherwise.
     */
    public boolean hasCapability(final int capability, final boolean defaultVal) {
        if (mSelectedCastDevice != null) {
            return mSelectedCastDevice.hasCapability(capability);
        } else {
            return defaultVal;
        }
    }

    /**
     * Adds and wires up the Switchable Media Router cast button. It returns a reference to the
     * {@link SwitchableMediaRouteActionProvider} associated with the button if the caller needs
     * such reference. It is assumed that the enclosing
     * {@link android.app.Activity} inherits (directly or indirectly) from
     * {@link android.support.v7.app.AppCompatActivity}.
     *
     * @param menuItem MenuItem of the Media Router cast button.
     */
    public final SwitchableMediaRouteActionProvider addMediaRouterButton(MenuItem menuItem) {
        SwitchableMediaRouteActionProvider mediaRouteActionProvider = (SwitchableMediaRouteActionProvider) MenuItemCompat
                .getActionProvider(menuItem);
        mediaRouteActionProvider.setRouteSelector(mMediaRouteSelector);
        if (mCastConfiguration.getMediaRouteDialogFactory() != null) {
            mediaRouteActionProvider.setDialogFactory(mCastConfiguration.getMediaRouteDialogFactory());
        }
        return mediaRouteActionProvider;
    }

    /* (non-Javadoc)
     * These methods startReconnectionService and stopReconnectionService simply override the ones
     * from BaseCastManager with empty implementations because we handle the service ourselves, but
     * need to allow BaseCastManager to save current network information.
     */
    @Override
    protected void startReconnectionService(long mediaDurationLeft) {
        // Do nothing
    }

    @Override
    protected void stopReconnectionService() {
        // Do nothing
    }
}