// Copyright 2015 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.content.Context;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Handler;
import android.os.SystemClock;
import android.support.v7.media.MediaControlIntent;
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 com.google.android.gms.cast.CastMediaControlIntent;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.annotations.RemovableInRelease;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.media.remote.RemoteVideoInfo.PlayerState;
import org.chromium.ui.widget.Toast;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import javax.annotation.Nullable;
/**
* Class containing the common, connection type independent, code for all MediaRouteControllers.
*/
public abstract class AbstractMediaRouteController implements MediaRouteController {
/**
* Callback class for monitoring whether any routes exist, and hence deciding whether to show
* the cast UI to users.
*/
private class DeviceDiscoveryCallback extends MediaRouter.Callback {
@Override
public void onProviderAdded(MediaRouter router, MediaRouter.ProviderInfo provider) {
updateRouteAvailability();
}
@Override
public void onProviderChanged(
MediaRouter router, MediaRouter.ProviderInfo provider) {
updateRouteAvailability();
}
@Override
public void onProviderRemoved(
MediaRouter router, MediaRouter.ProviderInfo provider) {
updateRouteAvailability();
}
@Override
public void onRouteAdded(MediaRouter router, RouteInfo route) {
logRoute("Added route", route);
updateRouteAvailability();
}
@Override
public void onRouteRemoved(MediaRouter router, RouteInfo route) {
logRoute("Removed route", route);
updateRouteAvailability();
}
@Override
public void onRouteChanged(MediaRouter router, RouteInfo route) {
logRoute("Changed route", route);
updateRouteAvailability();
}
private void updateRouteAvailability() {
if (mediaRouterInitializationFailed()) return;
boolean routesAvailable = getMediaRouter().isRouteAvailable(mMediaRouteSelector,
MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE);
if (routesAvailable != mRoutesAvailable) {
mRoutesAvailable = routesAvailable;
Log.d(TAG, "Remote media route availability changed, updating listeners");
for (MediaStateListener listener : mAvailableRouteListeners) {
listener.onRouteAvailabilityChanged(routesAvailable);
}
}
}
}
/**
* Callback class for monitoring whether a route has been selected, and the state of the
* selected route.
*/
private class DeviceSelectionCallback extends MediaRouter.Callback {
// Note that this doesn't use onRouteSelected, but instead casting is started directly
// by the selection dialog. It has to be done that way, since selecting the current route
// on a new video doesn't call onRouteSelected.
private Runnable mConnectionFailureNotifier = new Runnable() {
@Override
public void run() {
release();
mConnectionFailureNotifierQueued = false;
}
};
/** True if we are waiting for the MediaRouter route to connect or reconnect */
private boolean mConnectionFailureNotifierQueued = false;
private void clearConnectionFailureCallback() {
getHandler().removeCallbacks(mConnectionFailureNotifier);
mConnectionFailureNotifierQueued = false;
}
@Override
public void onRouteChanged(MediaRouter router, RouteInfo route) {
// We only care about changes to the current route.
if (!route.equals(getCurrentRoute())) return;
// When there is no wifi connection, this condition becomes true.
if (route.isConnecting()) {
// We don't want to post the same Runnable twice.
if (!mConnectionFailureNotifierQueued) {
mConnectionFailureNotifierQueued = true;
getHandler().postDelayed(mConnectionFailureNotifier,
CONNECTION_FAILURE_NOTIFICATION_DELAY_MS);
}
} else {
// Only cancel the disconnect if we already posted the message. We can get into this
// situation if we swap the current route provider (for example, switching to a YT
// video while casting a non-YT video).
if (mConnectionFailureNotifierQueued) {
// We have reconnected, cancel the delayed disconnect.
getHandler().removeCallbacks(mConnectionFailureNotifier);
mConnectionFailureNotifierQueued = false;
}
}
}
@Override
public void onRouteUnselected(MediaRouter router, RouteInfo route) {
onRouteUnselectedEvent(router, route);
if (getCurrentRoute() != null && !getCurrentRoute().isDefault()
&& route.getId().equals(getCurrentRoute().getId())) {
RecordCastAction.castEndedTimeRemaining(getDuration(),
getDuration() - getPosition());
release();
}
}
}
/** Number of ms to wait for reconnection, after which we call the failure callbacks. */
protected static final long CONNECTION_FAILURE_NOTIFICATION_DELAY_MS = 10000L;
private static final long END_OF_VIDEO_THRESHOLD_MS = 500L;
private static final String TAG = "MediaFling";
private final Set<MediaStateListener> mAvailableRouteListeners;
private final Context mContext;
private RouteInfo mCurrentRoute;
private final DeviceDiscoveryCallback mDeviceDiscoveryCallback;;
private final DeviceSelectionCallback mDeviceSelectionCallback;
private final Handler mHandler;
private boolean mIsPrepared = false;
private final MediaRouter mMediaRouter;
private final MediaRouteSelector mMediaRouteSelector;
/**
* The media state listener connects to the web page that requested casting. It will be null if
* that page is no longer in a tab, but closing the page or tab should not stop cast. Cast can
* still be controlled through the notification even if the page is closed.
*/
private MediaStateListener mMediaStateListener;
// There are times when the player state shown to user (e.g. just after pressing the pause
// button) should update before we receive an update from the Chromecast, so we have to track
// two player states.
private PlayerState mRemotePlayerState = PlayerState.FINISHED;
private PlayerState mDisplayedPlayerState = PlayerState.FINISHED;
private boolean mRoutesAvailable = false;
private final Set<UiListener> mUiListeners;
private boolean mWatchingRouteSelection = false;
private long mMediaElementAttachedTimestampMs = 0;
private long mMediaElementDetachedTimestampMs = 0;
protected AbstractMediaRouteController() {
mContext = ContextUtils.getApplicationContext();
assert (getContext() != null);
mHandler = new Handler();
mMediaRouteSelector = buildMediaRouteSelector();
MediaRouter mediaRouter;
try {
// Pre-MR1 versions of JB do not have the complete MediaRouter APIs,
// so getting the MediaRouter instance will throw an exception.
mediaRouter = MediaRouter.getInstance(getContext());
} catch (NoSuchMethodError e) {
Log.e(TAG, "Can't get an instance of MediaRouter, casting is not supported."
+ " Are you still on JB (JVP15S)?");
mediaRouter = null;
}
mMediaRouter = mediaRouter;
mAvailableRouteListeners = new HashSet<MediaStateListener>();
// TODO(aberent): I am unclear why this is accessed from multiple threads, but
// if I make it a HashSet then it gets ConcurrentModificationExceptions on some
// types of disconnect. Investigate and fix.
mUiListeners = new CopyOnWriteArraySet<UiListener>();
mDeviceDiscoveryCallback = new DeviceDiscoveryCallback();
mDeviceSelectionCallback = new DeviceSelectionCallback();
}
@Override
public void addMediaStateListener(MediaStateListener listener) {
if (mediaRouterInitializationFailed()) return;
if (mAvailableRouteListeners.isEmpty()) {
getMediaRouter().addCallback(mMediaRouteSelector, mDeviceDiscoveryCallback,
MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY);
Log.d(TAG, "Started device discovery");
// Get the initial state
mRoutesAvailable = getMediaRouter().isRouteAvailable(
mMediaRouteSelector, MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE);
}
mAvailableRouteListeners.add(listener);
// Send the current state to the listener.
listener.onRouteAvailabilityChanged(mRoutesAvailable);
}
@Override
public void addUiListener(UiListener listener) {
mUiListeners.add(listener);
}
protected void clearConnectionFailureCallback() {
mDeviceSelectionCallback.clearConnectionFailureCallback();
}
/**
* Clear the current playing item (if any) but not the associated session.
*/
protected void clearItemState() {
mRemotePlayerState = PlayerState.FINISHED;
mDisplayedPlayerState = PlayerState.FINISHED;
updateTitle(null);
}
/**
* Reset the media route to the default
*/
protected void clearMediaRoute() {
if (getMediaRouter() != null) {
getMediaRouter().getDefaultRoute().select();
registerRoute(getMediaRouter().getDefaultRoute());
}
}
@Override
public boolean currentRouteSupportsRemotePlayback() {
return mCurrentRoute != null && mCurrentRoute.supportsControlCategory(
MediaControlIntent.CATEGORY_REMOTE_PLAYBACK);
}
protected final Context getContext() {
return mContext;
}
protected final RouteInfo getCurrentRoute() {
return mCurrentRoute;
}
protected final Handler getHandler() {
return mHandler;
}
protected final MediaRouter getMediaRouter() {
return mMediaRouter;
}
@Override
public final MediaStateListener getMediaStateListener() {
return mMediaStateListener;
}
public final PlayerState getRemotePlayerState() {
return mRemotePlayerState;
}
@Override
public final PlayerState getDisplayedPlayerState() {
return mDisplayedPlayerState;
}
@Override
public final String getRouteName() {
return mCurrentRoute == null ? null : mCurrentRoute.getName();
}
protected final Set<UiListener> getUiListeners() {
return mUiListeners;
}
private final boolean isAtEndOfVideo(long positionMs, long videoLengthMs) {
return videoLengthMs - positionMs < END_OF_VIDEO_THRESHOLD_MS && videoLengthMs > 0;
}
@Override
public final boolean isBeingCast() {
return (mIsPrepared && mRemotePlayerState != PlayerState.INVALIDATED
&& mRemotePlayerState != PlayerState.ERROR
&& mRemotePlayerState != PlayerState.FINISHED);
}
@Override
public final boolean isPlaying() {
return mRemotePlayerState == PlayerState.PLAYING
|| mRemotePlayerState == PlayerState.LOADING;
}
@Override
public final boolean isRemotePlaybackAvailable() {
if (mediaRouterInitializationFailed()) return false;
return getMediaRouter().getSelectedRoute().getPlaybackType()
== MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE || getMediaRouter().isRouteAvailable(
mMediaRouteSelector, MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE);
}
protected final boolean mediaRouterInitializationFailed() {
return getMediaRouter() == null;
}
protected final void notifyRouteSelected(RouteInfo route) {
for (UiListener listener : mUiListeners) {
listener.onRouteSelected(route.getName(), this);
}
if (!canCastMedia()) return;
if (mMediaStateListener == null) return;
mMediaStateListener.pauseLocal();
mMediaStateListener.onCastStarting(route.getName());
startCastingVideo();
}
// This exists for compatibility with old downstream code
// TODO(aberent) convert to abstract
protected void startCastingVideo() {
String url = mMediaStateListener.getSourceUrl();
Uri uri = url == null ? null : Uri.parse(url);
setDataSource(uri, mMediaStateListener.getCookies());
prepareAsync(
mMediaStateListener.getFrameUrl(), mMediaStateListener.getStartPositionMillis());
}
private boolean canCastMedia() {
return isRemotePlaybackAvailable() && !routeIsDefaultRoute()
&& currentRouteSupportsRemotePlayback();
}
protected void onRouteAddedEvent(MediaRouter router, RouteInfo route) {
};
// TODO(aberent): Merge with onRouteSelected(). Needs two sided patch for downstream
// implementations
protected void onRouteSelectedEvent(MediaRouter router, RouteInfo route) {
}
@Override
public void onRouteSelected(MediaStateListener player, MediaRouter router, RouteInfo route) {
if (mMediaStateListener != null) mMediaStateListener.onCastStopping();
setMediaStateListener(player);
onRouteSelectedEvent(router, route);
}
protected abstract void onRouteUnselectedEvent(MediaRouter router, RouteInfo route);
@Override
public void prepareMediaRoute() {
startWatchingRouteSelection();
}
@Override
public void release() {
recordEndOfSessionUMA();
}
private void recordEndOfSessionUMA() {
long remotePlaybackStoppedTimestampMs = SystemClock.elapsedRealtime();
// There was no media element ever...
if (mMediaElementAttachedTimestampMs == 0) return;
long remotePlaybackIntervalMs =
remotePlaybackStoppedTimestampMs - mMediaElementAttachedTimestampMs;
if (mMediaElementDetachedTimestampMs == 0) {
mMediaElementDetachedTimestampMs = remotePlaybackStoppedTimestampMs;
}
int noElementRemotePlaybackTimePercentage =
(int) ((remotePlaybackStoppedTimestampMs - mMediaElementDetachedTimestampMs) * 100
/ remotePlaybackIntervalMs);
RecordCastAction.recordRemoteSessionTimeWithoutMediaElementPercentage(
noElementRemotePlaybackTimePercentage);
mMediaElementAttachedTimestampMs = 0;
mMediaElementDetachedTimestampMs = 0;
}
protected final void registerRoute(RouteInfo route) {
mCurrentRoute = route;
logRoute("Selected route", route);
}
@RemovableInRelease
private void logRoute(String message, RouteInfo route) {
if (route != null) {
Log.d(TAG, message + " " + route.getName() + " " + route.getId());
}
}
protected void removeAllListeners() {
mUiListeners.clear();
}
@Override
public void removeMediaStateListener(MediaStateListener listener) {
if (mediaRouterInitializationFailed()) return;
mAvailableRouteListeners.remove(listener);
if (mAvailableRouteListeners.isEmpty()) {
getMediaRouter().removeCallback(mDeviceDiscoveryCallback);
Log.d(TAG, "Stopped device discovery");
}
}
@Override
public void removeUiListener(UiListener listener) {
mUiListeners.remove(listener);
}
@Override
public boolean routeIsDefaultRoute() {
return mCurrentRoute != null && mCurrentRoute.isDefault();
}
protected void sendErrorToListeners(int error) {
String errorMessage =
getContext().getString(R.string.cast_error_playing_video, mCurrentRoute.getName());
for (UiListener listener : mUiListeners) {
listener.onError(error, errorMessage);
}
if (mMediaStateListener != null) mMediaStateListener.onError();
}
@Override
public void setMediaStateListener(MediaStateListener mediaStateListener) {
if (mMediaStateListener != null && mediaStateListener == null
&& mMediaElementAttachedTimestampMs != 0) {
mMediaElementDetachedTimestampMs = SystemClock.elapsedRealtime();
} else if (mMediaStateListener == null && mediaStateListener != null) {
// We're switching the videos so let's record the UMA for the previous one.
if (mMediaElementDetachedTimestampMs != 0) recordEndOfSessionUMA();
mMediaElementAttachedTimestampMs = SystemClock.elapsedRealtime();
mMediaElementDetachedTimestampMs = 0;
}
mMediaStateListener = mediaStateListener;
}
private void onCasting() {
if (!mIsPrepared) {
for (UiListener listener : mUiListeners) {
listener.onPrepared(this);
}
if (mMediaStateListener != null) {
if (mMediaStateListener.isPauseRequested()) pause();
if (mMediaStateListener.isSeekRequested()) {
seekTo(mMediaStateListener.getSeekLocation());
} else {
seekTo(mMediaStateListener.getLocalPosition());
}
}
RecordCastAction.castDefaultPlayerResult(true);
mIsPrepared = true;
}
}
protected void setUnprepared() {
mIsPrepared = false;
}
protected void showCastError(String routeName) {
Toast toast = Toast.makeText(
getContext(),
getContext().getString(R.string.cast_error_playing_video, routeName),
Toast.LENGTH_SHORT);
toast.show();
}
private void startWatchingRouteSelection() {
if (mWatchingRouteSelection || mediaRouterInitializationFailed()) return;
mWatchingRouteSelection = true;
// Start listening
getMediaRouter().addCallback(mMediaRouteSelector, mDeviceSelectionCallback,
MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY);
Log.d(TAG, "Started route selection discovery");
}
protected void stopWatchingRouteSelection() {
mWatchingRouteSelection = false;
if (getMediaRouter() != null) {
getMediaRouter().removeCallback(mDeviceSelectionCallback);
Log.d(TAG, "Stopped route selection discovery");
}
}
@VisibleForTesting
void setPlayerStateForMediaItemState(int state) {
PlayerState playerState = PlayerState.STOPPED;
switch (state) {
case MediaItemStatus.PLAYBACK_STATE_BUFFERING:
playerState = PlayerState.LOADING;
break;
case MediaItemStatus.PLAYBACK_STATE_CANCELED:
playerState = PlayerState.FINISHED;
break;
case MediaItemStatus.PLAYBACK_STATE_ERROR:
playerState = PlayerState.ERROR;
break;
case MediaItemStatus.PLAYBACK_STATE_FINISHED:
playerState = PlayerState.FINISHED;
break;
case MediaItemStatus.PLAYBACK_STATE_INVALIDATED:
playerState = PlayerState.INVALIDATED;
break;
case MediaItemStatus.PLAYBACK_STATE_PAUSED:
if (isAtEndOfVideo(getPosition(), getDuration())) {
playerState = PlayerState.FINISHED;
} else {
playerState = PlayerState.PAUSED;
}
break;
case MediaItemStatus.PLAYBACK_STATE_PENDING:
playerState = PlayerState.PAUSED;
break;
case MediaItemStatus.PLAYBACK_STATE_PLAYING:
playerState = PlayerState.PLAYING;
break;
default:
break;
}
mRemotePlayerState = playerState;
}
protected void updateState(int state) {
Log.d(TAG, "updateState oldState: %s player state: %s", mRemotePlayerState, state);
PlayerState oldState = mRemotePlayerState;
setPlayerStateForMediaItemState(state);
Log.d(TAG, "updateState newState: %s", mRemotePlayerState);
if (oldState != mRemotePlayerState) {
setDisplayedPlayerState(mRemotePlayerState);
switch (mRemotePlayerState) {
case PLAYING:
onCasting();
break;
case PAUSED:
onCasting();
break;
case FINISHED:
release();
break;
case INVALIDATED:
clearItemState();
break;
case ERROR:
sendErrorToListeners(CastMediaControlIntent.ERROR_CODE_REQUEST_FAILED);
release();
break;
default:
break;
}
}
}
protected void setDisplayedPlayerState(PlayerState state) {
mDisplayedPlayerState = state;
for (UiListener listener : mUiListeners) {
listener.onPlaybackStateChanged(mDisplayedPlayerState);
}
if (mMediaStateListener != null) {
mMediaStateListener.onPlaybackStateChanged(mDisplayedPlayerState);
}
}
protected void updateTitle(@Nullable String newTitle) {
for (UiListener listener : mUiListeners) {
listener.onTitleChanged(newTitle);
}
}
@Override
public Bitmap getPoster() {
if (getMediaStateListener() == null) return null;
return getMediaStateListener().getPosterBitmap();
}
// This exists for compatibility with old downstream code
// TODO(aberent) remove
protected void prepareAsync(String frameUrl, long startPositionMillis){};
// This exists for compatibility with old downstream code
// TODO(aberent) remove
protected void setDataSource(Uri uri, String cookies){};
protected boolean reconnectAnyExistingRoute() {
// Temp version to avoid two sided patch while removing
return false;
};
@Override
public void checkIfPlayableRemotely(String sourceUrl, String frameUrl, String cookies,
String userAgent, MediaValidationCallback callback) {
callback.onResult(true, sourceUrl, frameUrl);
}
@Override
public String getUriPlaying() {
return null;
}
// Used by J
void setPreparedForTesting() {
mIsPrepared = true;
}
}