Index: chrome/android/java/src/org/chromium/chrome/browser/media/remote/DefaultMediaRouteController.java |
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/media/remote/DefaultMediaRouteController.java b/chrome/android/java/src/org/chromium/chrome/browser/media/remote/DefaultMediaRouteController.java |
new file mode 100644 |
index 0000000000000000000000000000000000000000..027b5ef5bbe89b6c2047d0fd74b700fd626b7617 |
--- /dev/null |
+++ b/chrome/android/java/src/org/chromium/chrome/browser/media/remote/DefaultMediaRouteController.java |
@@ -0,0 +1,1101 @@ |
+// Copyright 2012 The Chromium Authors. All rights reserved. |
+// Use of this source code is governed by a BSD-style license that can be |
+// found in the LICENSE file. |
+ |
+package org.chromium.chrome.browser.media.remote; |
+ |
+import android.app.PendingIntent; |
+import android.content.BroadcastReceiver; |
+import android.content.Context; |
+import android.content.Intent; |
+import android.content.IntentFilter; |
+import android.net.Uri; |
+import android.os.Bundle; |
+import android.os.SystemClock; |
+import android.support.v7.media.MediaControlIntent; |
+import android.support.v7.media.MediaItemMetadata; |
+import android.support.v7.media.MediaItemStatus; |
+import android.support.v7.media.MediaRouteSelector; |
+import android.support.v7.media.MediaRouter; |
+import android.support.v7.media.MediaRouter.RouteInfo; |
+import android.support.v7.media.MediaSessionStatus; |
+import android.util.Log; |
+import android.widget.Toast; |
+ |
+import com.google.android.gms.cast.CastMediaControlIntent; |
+ |
+import org.apache.http.Header; |
+import org.chromium.base.ApplicationState; |
+import org.chromium.base.ApplicationStatus; |
+import org.chromium.base.CommandLine; |
+import org.chromium.chrome.ChromeSwitches; |
+import org.chromium.chrome.R; |
+import org.chromium.chrome.browser.media.remote.RemoteVideoInfo.PlayerState; |
+ |
+import java.net.URI; |
+import java.net.URISyntaxException; |
+ |
+import javax.annotation.Nullable; |
+ |
+/** |
+ * Class that abstracts all communication to and from the Android MediaRoutes. This class is |
+ * responsible for connecting to the MRs as well as sending commands and receiving status updates |
+ * from the remote player. |
+ * |
+ * We have three main scenarios for Cast: |
+ * |
+ * - the first cast: user plays the first video on the Chromecast so we start a new session with |
+ * the player and fling the video |
+ * |
+ * - the consequent cast: users plays another video while the previous one is still playing |
+ * remotely meaning that we don't have to start the session but to replace the current video with |
+ * the new one |
+ * |
+ * - the reconnect: if Clank crashes, we need to try to reconnect to the existing session and |
+ * continue controlling the currently playing video. |
+ * |
+ * Casting the first video takes three intents sent to the selected media route: |
+ * ACTION_START_SESSION, ACTION_SYNC_STATUS and ACTION_PLAY. The first one is sent before anything |
+ * else. We get the session id from the result bundle of the intent but need to wait until the |
+ * session becomes active before continuing to the next step. Then we send the ACTION_SYNC_STATUS |
+ * intent to update the media item status and pass the PendingIntent for the media item status |
+ * events to the Cast MRP. Finally we send the video URL via the ACTION_PLAY intent. |
+ * |
+ * Casting the second video should only take one ACTION_PLAY intent if the session is still active. |
+ * Otherwise, the scenario is the same as for the first video. However, due to the crbug.com/336188 |
+ * we need to restart the session for each ACTION_PLAY so we go through the same process as above. |
+ * |
+ * In order to reconnect, we need to programmatically select the previously selected media route. |
+ * To do this we send an ACTION_START_SESSION with the old session ID. This is not clearly |
+ * documented in the Android documentation, but seems to only succeed if the session still exists. |
+ * Otherwise we need to start a new session. |
+ * |
+ * Note that, if the Chrome cast notification restarts following a crash, instances of this class |
+ * may exist before the C++ library has been loaded. As such this class should avoid using anything |
+ * that might use the C++ library (almost anything else in Chrome) or check that the library is |
+ * loaded before using them (as it does for recording UMA statistics). |
+ */ |
+public class DefaultMediaRouteController extends AbstractMediaRouteController { |
+ |
+ /** |
+ * Interface for MediaRouter intents result handlers. |
+ */ |
+ protected interface ResultBundleHandler { |
+ void onResult(Bundle data); |
+ |
+ void onError(String message, Bundle data); |
+ } |
+ |
+ private static final String TAG = "DefaultMediaRouteController"; |
+ |
+ private static final String ACTION_RECEIVE_SESSION_STATUS_UPDATE = |
+ "com.google.android.apps.chrome.videofling.RECEIVE_SESSION_STATUS_UPDATE"; |
+ private static final String ACTION_RECEIVE_MEDIA_STATUS_UPDATE = |
+ "com.google.android.apps.chrome.videofling.RECEIVE_MEDIA_STATUS_UPDATE"; |
+ private static final String MIME_TYPE = "video/mp4"; |
+ |
+ private boolean mDebug; |
+ private String mCurrentSessionId; |
+ private String mCurrentItemId; |
+ private int mStreamPositionTimestamp; |
+ private int mLastKnownStreamPosition; |
+ private int mStreamDuration; |
+ private boolean mSeeking; |
+ private final String mIntentCategory; |
+ private PendingIntent mSessionStatusUpdateIntent; |
+ private BroadcastReceiver mSessionStatusBroadcastReceiver; |
+ private PendingIntent mMediaStatusUpdateIntent; |
+ private BroadcastReceiver mMediaStatusBroadcastReceiver; |
+ private boolean mReconnecting = false; |
+ |
+ private Uri mVideoUriToStart; |
+ private String mPreferredTitle; |
+ private long mStartPositionMillis; |
+ |
+ private Uri mLocalVideoUri; |
+ |
+ private String mLocalVideoCookies; |
+ |
+ private MediaUrlResolver mMediaUrlResolver; |
+ |
+ private int mSessionState = MediaSessionStatus.SESSION_STATE_INVALIDATED; |
+ |
+ // Media types supported for cast, see |
+ // media/base/container_names.h for the actual enum where these are defined |
+ private static final int UNKNOWN_MEDIA = 0; |
+ private static final int SMOOTHSTREAM_MEDIA = 39; |
+ private static final int DASH_MEDIA = 38; |
+ private static final int HLS_MEDIA = 22; |
+ private static final int MPEG4_MEDIA = 29; |
+ |
+ private final ApplicationStatus.ApplicationStateListener |
+ mApplicationStateListener = new ApplicationStatus.ApplicationStateListener() { |
+ @Override |
+ public void onApplicationStateChange(int newState) { |
+ switch (newState) { |
+ // HAS_DESTROYED_ACTIVITIES means all Chrome activities have been destroyed. |
+ case ApplicationState.HAS_DESTROYED_ACTIVITIES: |
+ onActivitiesDestroyed(); |
+ break; |
+ default: |
+ break; |
+ } |
+ } |
+ }; |
+ |
+ private final MediaUrlResolver.Delegate |
+ mMediaUrlResolverDelegate = new MediaUrlResolver.Delegate() { |
+ @Override |
+ public Uri getUri() { |
+ return mLocalVideoUri; |
+ } |
+ |
+ @Override |
+ public String getCookies() { |
+ return mLocalVideoCookies; |
+ } |
+ |
+ @Override |
+ public void setUri(Uri uri, Header[] headers) { |
+ if (canPlayMedia(uri, headers)) { |
+ mLocalVideoUri = uri; |
+ playMedia(); |
+ return; |
+ } |
+ mLocalVideoUri = null; |
+ showMessageToast( |
+ getContext().getString(R.string.cast_permission_error_playing_video)); |
+ release(); |
+ } |
+ }; |
+ |
+ /** |
+ * Default and only constructor. |
+ */ |
+ public DefaultMediaRouteController() { |
+ mDebug = CommandLine.getInstance().hasSwitch(ChromeSwitches.ENABLE_CAST_DEBUG_LOGS); |
+ mIntentCategory = getContext().getPackageName(); |
+ } |
+ |
+ @Override |
+ public boolean initialize() { |
+ if (mediaRouterInitializationFailed()) return false; |
+ |
+ ApplicationStatus.registerApplicationStateListener(mApplicationStateListener); |
+ |
+ if (mSessionStatusUpdateIntent == null) { |
+ Intent sessionStatusUpdateIntent = new Intent(ACTION_RECEIVE_SESSION_STATUS_UPDATE); |
+ sessionStatusUpdateIntent.addCategory(mIntentCategory); |
+ mSessionStatusUpdateIntent = PendingIntent.getBroadcast(getContext(), 0, |
+ sessionStatusUpdateIntent, PendingIntent.FLAG_UPDATE_CURRENT); |
+ } |
+ |
+ if (mMediaStatusUpdateIntent == null) { |
+ Intent mediaStatusUpdateIntent = new Intent(ACTION_RECEIVE_MEDIA_STATUS_UPDATE); |
+ mediaStatusUpdateIntent.addCategory(mIntentCategory); |
+ mMediaStatusUpdateIntent = PendingIntent.getBroadcast(getContext(), 0, |
+ mediaStatusUpdateIntent, PendingIntent.FLAG_UPDATE_CURRENT); |
+ } |
+ |
+ return true; |
+ } |
+ |
+ @Override |
+ public boolean canPlayMedia(String sourceUrl, String frameUrl) { |
+ |
+ if (mediaRouterInitializationFailed()) return false; |
+ |
+ if (sourceUrl == null) return false; |
+ |
+ try { |
+ String scheme = new URI(sourceUrl).getScheme(); |
+ if (scheme == null) return false; |
+ return scheme.equals("http") || scheme.equals("https"); |
+ } catch (URISyntaxException e) { |
+ return false; |
+ } |
+ } |
+ |
+ @Override |
+ public void setRemoteVolume(int delta) { |
+ boolean canChangeRemoteVolume = (getCurrentRoute().getVolumeHandling() |
+ == MediaRouter.RouteInfo.PLAYBACK_VOLUME_VARIABLE); |
+ if (currentRouteSupportsRemotePlayback() && canChangeRemoteVolume) { |
+ getCurrentRoute().requestUpdateVolume(delta); |
+ } |
+ } |
+ |
+ @Override |
+ public MediaRouteSelector buildMediaRouteSelector() { |
+ return new MediaRouteSelector.Builder().addControlCategory( |
+ CastMediaControlIntent.categoryForRemotePlayback(getCastReceiverId())).build(); |
+ } |
+ |
+ protected String getCastReceiverId() { |
+ return CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID; |
+ } |
+ |
+ @Override |
+ public boolean reconnectAnyExistingRoute() { |
+ String deviceId = RemotePlaybackSettings.getDeviceId(getContext()); |
+ RouteInfo defaultRoute = getMediaRouter().getDefaultRoute(); |
+ if (deviceId == null || deviceId.equals(defaultRoute.getId()) || !shouldReconnect()) { |
+ RemotePlaybackSettings.setShouldReconnectToRemote(getContext(), false); |
+ return false; |
+ } |
+ mReconnecting = true; |
+ selectDevice(deviceId); |
+ getHandler().postDelayed(new Runnable() { |
+ @Override |
+ public void run() { |
+ if (mReconnecting) { |
+ Log.d(TAG, "Reconnection timed out"); |
+ // We have been trying to reconnect for too long. Give up and save battery. |
+ mReconnecting = false; |
+ release(); |
+ } |
+ } |
+ }, CONNECTION_FAILURE_NOTIFICATION_DELAY_MS); |
+ return true; |
+ } |
+ |
+ private boolean shouldReconnect() { |
+ if (CommandLine.getInstance().hasSwitch(ChromeSwitches.DISABLE_CAST_RECONNECTION)) { |
+ if (mDebug) Log.d(TAG, "Cast reconnection disabled"); |
+ return false; |
+ } |
+ boolean reconnect = false; |
+ if (RemotePlaybackSettings.getShouldReconnectToRemote(getContext())) { |
+ String lastState = RemotePlaybackSettings.getLastVideoState(getContext()); |
+ if (lastState != null) { |
+ PlayerState state = PlayerState.valueOf(lastState); |
+ if (state == PlayerState.PLAYING || state == PlayerState.LOADING) { |
+ // If we were playing when we got killed, check the time to |
+ // see if it's still |
+ // plausible that the remote video is playing currently |
+ long remainingPlaytime = RemotePlaybackSettings.getRemainingTime(getContext()); |
+ long lastPlayedTime = RemotePlaybackSettings.getLastPlayedTime(getContext()); |
+ long currentTime = System.currentTimeMillis(); |
+ if (currentTime < lastPlayedTime + remainingPlaytime) { |
+ reconnect = true; |
+ } |
+ } else if (state == PlayerState.PAUSED) { |
+ reconnect = true; |
+ } |
+ } |
+ } |
+ if (mDebug) Log.d(TAG, "shouldReconnect returning: " + reconnect); |
+ return reconnect; |
+ } |
+ |
+ /** |
+ * Tries to select a device with the given device ID. The device ID is cached so that if the |
+ * route does not exist yet, we will connect to it as soon as it comes back up again |
+ * |
+ * @param deviceId the ID of the device to connect to |
+ */ |
+ private void selectDevice(String deviceId) { |
+ if (deviceId == null) { |
+ release(); |
+ return; |
+ } |
+ |
+ setDeviceId(deviceId); |
+ |
+ if (mDebug) Log.d(TAG, "Trying to select " + getDeviceId()); |
+ |
+ // See if we can select the device at this point. |
+ if (getMediaRouter() != null) { |
+ for (MediaRouter.RouteInfo route : getMediaRouter().getRoutes()) { |
+ if (deviceId.equals(route.getId())) { |
+ route.select(); |
+ break; |
+ } |
+ } |
+ } |
+ } |
+ |
+ @Override |
+ public void resume() { |
+ if (mCurrentItemId == null) return; |
+ |
+ Intent intent = new Intent(MediaControlIntent.ACTION_RESUME); |
+ intent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK); |
+ intent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, mCurrentSessionId); |
+ sendIntentToRoute(intent, new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ processMediaStatusBundle(data); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ release(); |
+ } |
+ }); |
+ |
+ updateState(MediaItemStatus.PLAYBACK_STATE_BUFFERING); |
+ } |
+ |
+ @Override |
+ public void pause() { |
+ if (mCurrentItemId == null) return; |
+ |
+ Intent intent = new Intent(MediaControlIntent.ACTION_PAUSE); |
+ intent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK); |
+ intent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, mCurrentSessionId); |
+ sendIntentToRoute(intent, new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ processMediaStatusBundle(data); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ // Do not release the player just because of a failed pause |
+ // request. This can happen when pausing more than once for |
+ // example. |
+ } |
+ }); |
+ |
+ // Update the last known position to the current one so that we don't |
+ // jump back in time discarding whatever we extrapolated from the last |
+ // time the position was updated. |
+ mLastKnownStreamPosition = getPosition(); |
+ updateState(MediaItemStatus.PLAYBACK_STATE_PAUSED); |
+ } |
+ |
+ /** |
+ * Plays the given Uri on the currently selected player. This will replace any currently playing |
+ * video |
+ * |
+ * @param videoUri Uri of the video to play |
+ * @param preferredTitle the preferred title of the current playback session to display |
+ * @param startPositionMillis from which to start playing. |
+ */ |
+ private void playUri(final Uri videoUri, |
+ @Nullable final String preferredTitle, final long startPositionMillis) { |
+ |
+ RecordCastAction.castMediaType(getMediaType(videoUri)); |
+ installBroadcastReceivers(); |
+ |
+ // Check if we are reconnecting or have reconnected and are playing the same video |
+ if ((mReconnecting || mCurrentSessionId != null) |
+ && videoUri.toString().equals(RemotePlaybackSettings.getUriPlaying(getContext()))) { |
+ return; |
+ } |
+ |
+ // If the session is already started (meaning we are casting a video already), we simply |
+ // load the new URL with one ACTION_PLAY intent. |
+ if (mCurrentSessionId != null) { |
+ if (mDebug) Log.d(TAG, "Playing a new url: " + videoUri); |
+ |
+ RemotePlaybackSettings.setUriPlaying(getContext(), videoUri.toString()); |
+ |
+ // We keep the same session so only clear the playing item status. |
+ clearItemState(); |
+ startPlayback(videoUri, preferredTitle, startPositionMillis); |
+ return; |
+ } |
+ |
+ RemotePlaybackSettings.setPlayerInUse(getContext(), getCastReceiverId()); |
+ if (mDebug) { |
+ Log.d(TAG, "Sending stream to app: " + getCastReceiverId()); |
+ Log.d(TAG, "Url: " + videoUri); |
+ } |
+ |
+ startSession(true, null, new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ configureNewSession(data); |
+ |
+ mVideoUriToStart = videoUri; |
+ RemotePlaybackSettings.setUriPlaying(getContext(), videoUri.toString()); |
+ mPreferredTitle = preferredTitle; |
+ mStartPositionMillis = startPositionMillis; |
+ // Make sure we get a session status. If the session becomes active |
+ // immediately then the broadcast session status can arrive before we have |
+ // the session id, so this ensures we get it whatever happens. |
+ getSessionStatus(mCurrentSessionId); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ release(); |
+ RecordCastAction.castDefaultPlayerResult(false); |
+ } |
+ }); |
+ } |
+ |
+ /** |
+ * Send a start session intent. |
+ * |
+ * @param relaunch Whether we should relaunch the cast application. |
+ * @param resultBundleHandler BundleHandler to handle reply. |
+ */ |
+ private void startSession(boolean relaunch, String sessionId, |
+ ResultBundleHandler resultBundleHandler) { |
+ Intent intent = new Intent(MediaControlIntent.ACTION_START_SESSION); |
+ intent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK); |
+ |
+ intent.putExtra(CastMediaControlIntent.EXTRA_CAST_STOP_APPLICATION_WHEN_SESSION_ENDS, true); |
+ intent.putExtra(MediaControlIntent.EXTRA_SESSION_STATUS_UPDATE_RECEIVER, |
+ mSessionStatusUpdateIntent); |
+ intent.putExtra(CastMediaControlIntent.EXTRA_CAST_APPLICATION_ID, getCastReceiverId()); |
+ intent.putExtra(CastMediaControlIntent.EXTRA_CAST_RELAUNCH_APPLICATION, relaunch); |
+ if (sessionId != null) intent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, sessionId); |
+ |
+ if (mDebug) intent.putExtra(CastMediaControlIntent.EXTRA_DEBUG_LOGGING_ENABLED, true); |
+ |
+ sendIntentToRoute(intent, resultBundleHandler); |
+ } |
+ |
+ private void getSessionStatus(String sessionId) { |
+ Intent intent = new Intent(MediaControlIntent.ACTION_GET_SESSION_STATUS); |
+ intent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK); |
+ |
+ intent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, sessionId); |
+ |
+ sendIntentToRoute(intent, new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ if (mDebug) Log.d(TAG, "getSessionStatus result : " + bundleToString(data)); |
+ |
+ processSessionStatusBundle(data); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ release(); |
+ } |
+ }); |
+ } |
+ |
+ private void startPlayback(final Uri videoUri, @Nullable final String preferredTitle, |
+ final long startPositionMillis) { |
+ setUnprepared(); |
+ Intent intent = new Intent(MediaControlIntent.ACTION_PLAY); |
+ intent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK); |
+ intent.setDataAndType(videoUri, MIME_TYPE); |
+ intent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, mCurrentSessionId); |
+ intent.putExtra(MediaControlIntent.EXTRA_ITEM_STATUS_UPDATE_RECEIVER, |
+ mMediaStatusUpdateIntent); |
+ intent.putExtra(MediaControlIntent.EXTRA_ITEM_CONTENT_POSITION, startPositionMillis); |
+ |
+ if (preferredTitle != null) { |
+ Bundle metadata = new Bundle(); |
+ metadata.putString(MediaItemMetadata.KEY_TITLE, preferredTitle); |
+ intent.putExtra(MediaControlIntent.EXTRA_ITEM_METADATA, metadata); |
+ } |
+ |
+ sendIntentToRoute(intent, new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ mCurrentItemId = data.getString(MediaControlIntent.EXTRA_ITEM_ID); |
+ processMediaStatusBundle(data); |
+ RecordCastAction.castDefaultPlayerResult(true); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ release(); |
+ RecordCastAction.castDefaultPlayerResult(false); |
+ } |
+ }); |
+ } |
+ |
+ @Override |
+ public int getPosition() { |
+ boolean paused = (getPlayerState() != PlayerState.PLAYING); |
+ if ((mStreamPositionTimestamp != 0) && !mSeeking && !paused |
+ && (mLastKnownStreamPosition < mStreamDuration)) { |
+ |
+ long extrapolatedStreamPosition = mLastKnownStreamPosition |
+ + (SystemClock.uptimeMillis() - mStreamPositionTimestamp); |
+ if (extrapolatedStreamPosition > mStreamDuration) { |
+ extrapolatedStreamPosition = mStreamDuration; |
+ } |
+ return (int) extrapolatedStreamPosition; |
+ } |
+ return mLastKnownStreamPosition; |
+ } |
+ |
+ @Override |
+ public int getDuration() { |
+ return mStreamDuration; |
+ } |
+ |
+ @Override |
+ public void seekTo(int msec) { |
+ if (msec == getPosition()) return; |
+ // Update the position now since the MRP will update it only once the video is playing |
+ // remotely. In particular, if the video is paused, the MRP doesn't send the command until |
+ // the video is resumed. |
+ mLastKnownStreamPosition = msec; |
+ mSeeking = true; |
+ Intent intent = new Intent(MediaControlIntent.ACTION_SEEK); |
+ intent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK); |
+ intent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, mCurrentSessionId); |
+ intent.putExtra(MediaControlIntent.EXTRA_ITEM_ID, mCurrentItemId); |
+ intent.putExtra(MediaControlIntent.EXTRA_ITEM_CONTENT_POSITION, (long) msec); |
+ sendIntentToRoute(intent, new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ if (getMediaStateListener() != null) getMediaStateListener().onSeekCompleted(); |
+ processMediaStatusBundle(data); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ release(); |
+ } |
+ }); |
+ } |
+ |
+ @Override |
+ public void release() { |
+ for (UiListener listener : getUiListeners()) { |
+ listener.onRouteUnselected(this); |
+ } |
+ if (getMediaStateListener() != null) getMediaStateListener().onRouteUnselected(); |
+ setMediaStateListener(null); |
+ |
+ stopAndDisconnect(); |
+ } |
+ |
+ /** |
+ * Stop the current remote playback and release all associated resources. Resources will be |
+ * released even if the stop operation fails. |
+ */ |
+ private void stopAndDisconnect() { |
+ if (mediaRouterInitializationFailed()) return; |
+ if (mCurrentSessionId == null) { |
+ // This can happen if we disconnect after a failure (because the |
+ // media could not be casted). |
+ disconnect(true); |
+ return; |
+ } |
+ |
+ Intent stopIntent = new Intent(MediaControlIntent.ACTION_STOP); |
+ stopIntent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK); |
+ stopIntent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, mCurrentSessionId); |
+ |
+ sendIntentToRoute(stopIntent, new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ processMediaStatusBundle(data); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) {} |
+ }); |
+ |
+ Intent endSessionIntent = new Intent(MediaControlIntent.ACTION_END_SESSION); |
+ endSessionIntent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK); |
+ endSessionIntent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, mCurrentSessionId); |
+ |
+ sendIntentToRoute(endSessionIntent, new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ if (mDebug) { |
+ MediaSessionStatus status = MediaSessionStatus.fromBundle( |
+ data.getBundle(MediaControlIntent.EXTRA_SESSION_STATUS)); |
+ int sessionState = status.getSessionState(); |
+ Log.d(TAG, "Session state after ending session: " + sessionState); |
+ } |
+ |
+ for (UiListener listener : getUiListeners()) { |
+ listener.onPlaybackStateChanged(getPlayerState(), PlayerState.FINISHED); |
+ } |
+ |
+ if (getMediaStateListener() != null) { |
+ getMediaStateListener().onPlaybackStateChanged(PlayerState.FINISHED); |
+ } |
+ RecordCastAction.castEndedTimeRemaining(mStreamDuration, |
+ mStreamDuration - getPosition()); |
+ disconnect(true); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ disconnect(true); |
+ } |
+ }); |
+ } |
+ |
+ /** |
+ * Disconnect from the remote screen without stopping the media playing. use release() for |
+ * disconnect + stop. |
+ * |
+ * @param finishedWithRoute true if finished with route and remote device, false if just |
+ * shutting down Chrome. |
+ */ |
+ private void disconnect(boolean finishedWithRoute) { |
+ if (finishedWithRoute) { |
+ clearStreamState(); |
+ clearMediaRoute(); |
+ } |
+ |
+ if (mSessionStatusBroadcastReceiver != null) { |
+ getContext().unregisterReceiver(mSessionStatusBroadcastReceiver); |
+ mSessionStatusBroadcastReceiver = null; |
+ } |
+ if (mMediaStatusBroadcastReceiver != null) { |
+ getContext().unregisterReceiver(mMediaStatusBroadcastReceiver); |
+ mMediaStatusBroadcastReceiver = null; |
+ } |
+ clearConnectionFailureCallback(); |
+ |
+ stopWatchingRouteSelection(); |
+ removeAllListeners(); |
+ } |
+ |
+ @Override |
+ protected void onRouteAddedEvent(MediaRouter router, RouteInfo route) { |
+ if (mDebug) Log.d(TAG, "Added route " + route); |
+ if (getDeviceId() != null && getDeviceId().equals(route.getId())) { |
+ // This is the route we are waiting to connect to, select it. |
+ if (mDebug) Log.d(TAG, "Selecting Added Device " + route.getName()); |
+ route.select(); |
+ } |
+ } |
+ |
+ @Override |
+ protected void onRouteSelectedEvent(MediaRouter router, RouteInfo route) { |
+ if (mDebug) Log.d(TAG, "Selected route " + route); |
+ if (!route.isSelected()) return; |
+ |
+ RecordCastAction.remotePlaybackDeviceSelected( |
+ RecordCastAction.DEVICE_TYPE_CAST_GENERIC); |
+ installBroadcastReceivers(); |
+ |
+ if (getMediaStateListener() == null) { |
+ showCastError(route.getName()); |
+ release(); |
+ return; |
+ } |
+ |
+ registerRoute(route); |
+ if (shouldReconnect()) { |
+ startSession(false, RemotePlaybackSettings.getSessionId(getContext()), |
+ new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ configureNewSession(data); |
+ setUnprepared(); |
+ mReconnecting = false; |
+ // Make sure we get a session status. If the session becomes active |
+ // immediately then the broadcast session status can arrive before we |
+ // have the session id, so this ensures we get it whatever happens. |
+ getSessionStatus(mCurrentSessionId); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ // Ignore errors, the connection sometimes is bouncy on reconnection, |
+ // and the reconnection timer is still running so will tidy up if |
+ // we never manage to connect. |
+ } |
+ }); |
+ } else { |
+ clearStreamState(); |
+ mReconnecting = false; |
+ } |
+ |
+ notifyRouteSelected(route); |
+ } |
+ |
+ /* |
+ * Although our custom implementation of the disconnect button doesn't need this, it is |
+ * needed when the route is released due to, for example, another application stealing the |
+ * route, or when we switch to a YouTube video on the same device. |
+ */ |
+ @Override |
+ protected void onRouteUnselectedEvent(MediaRouter router, RouteInfo route) { |
+ if (mDebug) Log.d(TAG, "Unselected route " + route); |
+ // Preserve our best guess as to the final position; this is needed to reset the |
+ // local position while switching back to local playback. |
+ mLastKnownStreamPosition = getPosition(); |
+ if (getCurrentRoute() != null && route.getId().equals(getCurrentRoute().getId())) { |
+ clearStreamState(); |
+ } |
+ } |
+ |
+ private void installBroadcastReceivers() { |
+ if (mSessionStatusBroadcastReceiver == null) { |
+ mSessionStatusBroadcastReceiver = new BroadcastReceiver() { |
+ @Override |
+ public void onReceive(Context context, Intent intent) { |
+ if (mDebug) { |
+ dumpIntentToLog("Got a session broadcast intent from the MRP: ", intent); |
+ } |
+ Bundle statusBundle = intent.getExtras(); |
+ |
+ // Ignore null status bundles. |
+ if (statusBundle == null) return; |
+ |
+ // Ignore the status of old sessions. |
+ String sessionId = statusBundle.getString(MediaControlIntent.EXTRA_SESSION_ID); |
+ if (mCurrentSessionId == null || !mCurrentSessionId.equals(sessionId)) return; |
+ |
+ processSessionStatusBundle(statusBundle); |
+ } |
+ }; |
+ IntentFilter sessionBroadcastIntentFilter = |
+ new IntentFilter(ACTION_RECEIVE_SESSION_STATUS_UPDATE); |
+ sessionBroadcastIntentFilter.addCategory(mIntentCategory); |
+ getContext().registerReceiver(mSessionStatusBroadcastReceiver, |
+ sessionBroadcastIntentFilter); |
+ } |
+ |
+ if (mMediaStatusBroadcastReceiver == null) { |
+ mMediaStatusBroadcastReceiver = new BroadcastReceiver() { |
+ @Override |
+ public void onReceive(Context context, Intent intent) { |
+ if (mDebug) dumpIntentToLog("Got a broadcast intent from the MRP: ", intent); |
+ |
+ processMediaStatusBundle(intent.getExtras()); |
+ } |
+ }; |
+ IntentFilter mediaBroadcastIntentFilter = |
+ new IntentFilter(ACTION_RECEIVE_MEDIA_STATUS_UPDATE); |
+ mediaBroadcastIntentFilter.addCategory(mIntentCategory); |
+ getContext().registerReceiver(mMediaStatusBroadcastReceiver, |
+ mediaBroadcastIntentFilter); |
+ } |
+ } |
+ |
+ /** |
+ * Called when the main activity receives an onDestroy() call. |
+ */ |
+ protected void onActivitiesDestroyed() { |
+ ApplicationStatus.unregisterApplicationStateListener(mApplicationStateListener); |
+ // It is important to not clear the stream state here to let Chrome |
+ // reconnect to a session upon startup. |
+ disconnect(false); |
+ } |
+ |
+ /** |
+ * Clear the session and the currently playing item (if any). |
+ */ |
+ protected void clearStreamState() { |
+ mVideoUriToStart = null; |
+ mLocalVideoUri = null; |
+ mCurrentSessionId = null; |
+ clearItemState(); |
+ |
+ if (getContext() != null) { |
+ RemotePlaybackSettings.setShouldReconnectToRemote(getContext(), false); |
+ RemotePlaybackSettings.setUriPlaying(getContext(), null); |
+ } |
+ } |
+ |
+ @Override |
+ protected void clearItemState() { |
+ // Note: do not clear the stream position, since this is still needed so |
+ // that we can reset the local stream position to match. |
+ super.clearItemState(); |
+ mCurrentItemId = null; |
+ mStreamPositionTimestamp = 0; |
+ mStreamDuration = 0; |
+ mSeeking = false; |
+ } |
+ |
+ private void syncStatus(String sessionId, ResultBundleHandler bundleHandler) { |
+ if (sessionId == null) return; |
+ Intent intent = new Intent(CastMediaControlIntent.ACTION_SYNC_STATUS); |
+ intent.addCategory(CastMediaControlIntent.categoryForRemotePlayback()); |
+ intent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, sessionId); |
+ intent.putExtra(MediaControlIntent.EXTRA_ITEM_STATUS_UPDATE_RECEIVER, |
+ mMediaStatusUpdateIntent); |
+ sendIntentToRoute(intent, bundleHandler); |
+ } |
+ |
+ private void processSessionStatusBundle(Bundle statusBundle) { |
+ MediaSessionStatus status = MediaSessionStatus.fromBundle( |
+ statusBundle.getBundle(MediaControlIntent.EXTRA_SESSION_STATUS)); |
+ int sessionState = status.getSessionState(); |
+ |
+ // If no change do nothing |
+ if (sessionState == mSessionState) return; |
+ mSessionState = sessionState; |
+ |
+ switch (sessionState) { |
+ case MediaSessionStatus.SESSION_STATE_ACTIVE: |
+ // TODO(aberent): This should not be needed. Remove this once b/12921924 is fixed. |
+ syncStatus(mCurrentSessionId, new ResultBundleHandler() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ processMediaStatusBundle(data); |
+ if (mVideoUriToStart != null) { |
+ startPlayback(mVideoUriToStart, mPreferredTitle, mStartPositionMillis); |
+ mVideoUriToStart = null; |
+ } |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ release(); |
+ } |
+ }); |
+ break; |
+ |
+ case MediaSessionStatus.SESSION_STATE_ENDED: |
+ case MediaSessionStatus.SESSION_STATE_INVALIDATED: |
+ for (UiListener listener : getUiListeners()) { |
+ listener.onPlaybackStateChanged(getPlayerState(), PlayerState.INVALIDATED); |
+ } |
+ if (getMediaStateListener() != null) { |
+ getMediaStateListener().onPlaybackStateChanged(PlayerState.INVALIDATED); |
+ } |
+ // Set the current session id to null so we don't send the stop intent. |
+ mCurrentSessionId = null; |
+ release(); |
+ break; |
+ |
+ default: |
+ break; |
+ } |
+ } |
+ |
+ private void processMediaStatusBundle(Bundle statusBundle) { |
+ if (statusBundle == null) return; |
+ |
+ if (mDebug) Log.d(TAG, "processMediaStatusBundle: " + bundleToString(statusBundle)); |
+ |
+ String itemId = statusBundle.getString(MediaControlIntent.EXTRA_ITEM_ID); |
+ if (itemId == null || !itemId.equals(mCurrentItemId)) return; |
+ |
+ // Extract item metadata, if available. |
+ if (statusBundle.containsKey(MediaControlIntent.EXTRA_ITEM_METADATA)) { |
+ Bundle metadataBundle = |
+ (Bundle) statusBundle.getParcelable(MediaControlIntent.EXTRA_ITEM_METADATA); |
+ updateTitle(metadataBundle.getString(MediaItemMetadata.KEY_TITLE)); |
+ } |
+ |
+ // Extract the item status, if available. |
+ if (statusBundle.containsKey(MediaControlIntent.EXTRA_ITEM_STATUS)) { |
+ Bundle itemStatusBundle = |
+ (Bundle) statusBundle.getParcelable(MediaControlIntent.EXTRA_ITEM_STATUS); |
+ MediaItemStatus itemStatus = MediaItemStatus.fromBundle(itemStatusBundle); |
+ |
+ if (mDebug) Log.d(TAG, "Received item status: " + bundleToString(itemStatusBundle)); |
+ |
+ updateState(itemStatus.getPlaybackState()); |
+ |
+ if ((getPlayerState() == PlayerState.PAUSED) |
+ || (getPlayerState() == PlayerState.PLAYING) |
+ || (getPlayerState() == PlayerState.LOADING)) { |
+ |
+ this.mCurrentItemId = itemId; |
+ |
+ int duration = (int) itemStatus.getContentDuration(); |
+ // duration can possibly be -1 if it's unknown, so cap to 0 |
+ updateDuration(Math.max(duration, 0)); |
+ |
+ // update the position using the remote player's position |
+ mLastKnownStreamPosition = (int) itemStatus.getContentPosition(); |
+ mStreamPositionTimestamp = (int) itemStatus.getTimestamp(); |
+ updatePosition(); |
+ |
+ if (mSeeking) { |
+ mSeeking = false; |
+ if (getMediaStateListener() != null) getMediaStateListener().onSeekCompleted(); |
+ } |
+ } |
+ |
+ Bundle extras = itemStatus.getExtras(); |
+ if (mDebug && extras != null) { |
+ if (extras.containsKey(MediaItemStatus.EXTRA_HTTP_STATUS_CODE)) { |
+ int httpStatus = extras.getInt(MediaItemStatus.EXTRA_HTTP_STATUS_CODE); |
+ Log.d(TAG, "HTTP status: " + httpStatus); |
+ } |
+ if (extras.containsKey(MediaItemStatus.EXTRA_HTTP_RESPONSE_HEADERS)) { |
+ Bundle headers = extras.getBundle(MediaItemStatus.EXTRA_HTTP_RESPONSE_HEADERS); |
+ Log.d(TAG, "HTTP headers: " + headers); |
+ } |
+ } |
+ } |
+ } |
+ |
+ /** |
+ * Send the given intent to the current route. The result will be returned in the given |
+ * ResultBundleHandler. This function will also check to see if the current route can handle the |
+ * intent before sending it. |
+ * |
+ * @param intent the intent to send to the current route. |
+ * @param bundleHandler contains the result of sending the intent |
+ */ |
+ private void sendIntentToRoute(final Intent intent, final ResultBundleHandler bundleHandler) { |
+ if (getCurrentRoute() == null) { |
+ if (mDebug) { |
+ dumpIntentToLog("sendIntentToRoute ", intent); |
+ Log.d(TAG, "The current route is null."); |
+ } |
+ if (bundleHandler != null) bundleHandler.onError(null, null); |
+ return; |
+ } |
+ |
+ if (!getCurrentRoute().supportsControlRequest(intent)) { |
+ if (mDebug) { |
+ dumpIntentToLog("sendIntentToRoute ", intent); |
+ Log.d(TAG, "The intent is not supported by the route: " + getCurrentRoute()); |
+ } |
+ if (bundleHandler != null) bundleHandler.onError(null, null); |
+ return; |
+ } |
+ |
+ sendControlIntent(intent, bundleHandler); |
+ } |
+ |
+ private void sendControlIntent(final Intent intent, final ResultBundleHandler bundleHandler) { |
+ |
+ if (mDebug) { |
+ Log.d(TAG, |
+ "Sending intent to " + getCurrentRoute().getName() + " " |
+ + getCurrentRoute().getId()); |
+ dumpIntentToLog("sendControlIntent ", intent); |
+ } |
+ if (getCurrentRoute().isDefault()) { |
+ if (mDebug) Log.d(TAG, "Route is default, not sending"); |
+ return; |
+ } |
+ |
+ getCurrentRoute().sendControlRequest(intent, new MediaRouter.ControlRequestCallback() { |
+ @Override |
+ public void onResult(Bundle data) { |
+ if (data != null && bundleHandler != null) bundleHandler.onResult(data); |
+ } |
+ |
+ @Override |
+ public void onError(String message, Bundle data) { |
+ if (mDebug) { |
+ // The intent may contain some PII so we don't want to log it in the released |
+ // version by default. |
+ Log.e(TAG, String.format( |
+ "Error sending control request %s %s. Data: %s Error: %s", intent, |
+ bundleToString(intent.getExtras()), bundleToString(data), message)); |
+ } |
+ |
+ int errorCode = 0; |
+ if (data != null) { |
+ errorCode = data.getInt(CastMediaControlIntent.EXTRA_ERROR_CODE); |
+ } |
+ |
+ sendErrorToListeners(errorCode); |
+ |
+ if (bundleHandler != null) bundleHandler.onError(message, data); |
+ } |
+ }); |
+ } |
+ |
+ private void updateDuration(int durationMillis) { |
+ mStreamDuration = durationMillis; |
+ |
+ for (UiListener listener : getUiListeners()) { |
+ listener.onDurationUpdated(durationMillis); |
+ } |
+ } |
+ |
+ private void updatePosition() { |
+ for (UiListener listener : getUiListeners()) { |
+ listener.onPositionChanged(getPosition()); |
+ } |
+ } |
+ |
+ private void dumpIntentToLog(String prefix, Intent intent) { |
+ Log.d(TAG, prefix + intent + " extras: " + bundleToString(intent.getExtras())); |
+ } |
+ |
+ private String bundleToString(Bundle bundle) { |
+ if (bundle == null) return ""; |
+ |
+ StringBuilder extras = new StringBuilder(); |
+ extras.append("["); |
+ for (String key : bundle.keySet()) { |
+ Object value = bundle.get(key); |
+ String valueText = value == null ? "null" : value.toString(); |
+ if (value instanceof Bundle) valueText = bundleToString((Bundle) value); |
+ extras.append(key).append("=").append(valueText).append(","); |
+ } |
+ extras.append("]"); |
+ return extras.toString(); |
+ } |
+ |
+ @Override |
+ public void setDataSource(Uri uri, String cookies) { |
+ if (mDebug) Log.d(TAG, "setDataSource called, uri = " + uri); |
+ mLocalVideoUri = uri; |
+ mLocalVideoCookies = cookies; |
+ } |
+ |
+ @Override |
+ public void prepareAsync(String frameUrl, long startPositionMillis) { |
+ if (mDebug) Log.d(TAG, "prepareAsync called, mLocalVideoUri = " + mLocalVideoUri); |
+ if (mLocalVideoUri == null) return; |
+ |
+ RecordCastAction.castPlayRequested(); |
+ |
+ // Cancel the previous task for URL resolving so that we don't get an old URI set. |
+ if (mMediaUrlResolver != null) mMediaUrlResolver.cancel(true); |
+ |
+ // Create a new MediaUrlResolver since the previous one may still be running despite the |
+ // cancel() call. |
+ mMediaUrlResolver = new MediaUrlResolver(getContext(), mMediaUrlResolverDelegate); |
+ |
+ mStartPositionMillis = startPositionMillis; |
+ mMediaUrlResolver.execute(); |
+ } |
+ |
+ private boolean canPlayMedia(Uri uri, Header[] headers) { |
+ if (uri == Uri.EMPTY) return false; |
+ |
+ // HLS media requires Cors headers. Since these are the only ones |
+ // sent now we can just check that headers is not empty but |
+ // if more headers are added we should be more strict in the check. |
+ if ((headers == null || headers.length == 0) && isEnhancedMedia(uri)) { |
+ if (mDebug) Log.d(TAG, "HLS stream without CORs header: " + uri); |
+ return false; |
+ } |
+ return true; |
+ } |
+ |
+ private void playMedia() { |
+ String title = getMediaStateListener().getTitle(); |
+ playUri(mLocalVideoUri, title, mStartPositionMillis); |
+ } |
+ |
+ private void showMessageToast(String message) { |
+ Toast toast = Toast.makeText(getContext(), message, Toast.LENGTH_SHORT); |
+ toast.show(); |
+ } |
+ |
+ private void configureNewSession(Bundle data) { |
+ mCurrentSessionId = data.getString(MediaControlIntent.EXTRA_SESSION_ID); |
+ mSessionState = MediaSessionStatus.SESSION_STATE_INVALIDATED; |
+ RemotePlaybackSettings.setSessionId(getContext(), mCurrentSessionId); |
+ if (mDebug) Log.d(TAG, "Got a session id: " + mCurrentSessionId); |
+ } |
+ |
+ private int getMediaType(Uri videoUri) { |
+ String uriString = videoUri.toString(); |
+ if (uriString.contains(".m3u8")) { |
+ return HLS_MEDIA; |
+ } |
+ if (uriString.contains(".mp4")) { |
+ return MPEG4_MEDIA; |
+ } |
+ if (uriString.contains(".mpd")) { |
+ return DASH_MEDIA; |
+ } |
+ if (uriString.contains(".ism")) { |
+ return SMOOTHSTREAM_MEDIA; |
+ } |
+ return UNKNOWN_MEDIA; |
+ } |
+ |
+ private boolean isEnhancedMedia(Uri videoUri) { |
+ int mediaType = getMediaType(videoUri); |
+ return mediaType == HLS_MEDIA || mediaType == DASH_MEDIA || mediaType == SMOOTHSTREAM_MEDIA; |
+ } |
+} |