/* * 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. */ package com.google.android.libraries.cast.companionlibrary.cast; import static com.google.android.libraries.cast.companionlibrary.utils.LogUtils.LOGD; import static com.google.android.libraries.cast.companionlibrary.utils.LogUtils.LOGE; import com.google.android.gms.cast.ApplicationMetadata; import com.google.android.gms.cast.Cast; import com.google.android.gms.cast.Cast.CastOptions.Builder; import com.google.android.gms.cast.Cast.MessageReceivedCallback; import com.google.android.gms.cast.CastDevice; 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.MediaTrack; import com.google.android.gms.cast.RemoteMediaPlayer; import com.google.android.gms.cast.RemoteMediaPlayer.MediaChannelResult; import com.google.android.gms.cast.TextTrackStyle; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GooglePlayServicesUtil; import com.google.android.gms.common.api.ResultCallback; import com.google.android.gms.common.api.Status; import com.google.android.gms.common.images.WebImage; import com.google.android.libraries.cast.companionlibrary.R; import com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumer; import com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumerImpl; 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 com.google.android.libraries.cast.companionlibrary.cast.player.MediaAuthService; import com.google.android.libraries.cast.companionlibrary.cast.player.VideoCastControllerActivity; import com.google.android.libraries.cast.companionlibrary.cast.tracks.OnTracksSelectedListener; import com.google.android.libraries.cast.companionlibrary.cast.tracks.TracksPreferenceManager; import com.google.android.libraries.cast.companionlibrary.notification.VideoCastNotificationService; import com.google.android.libraries.cast.companionlibrary.remotecontrol.VideoIntentReceiver; import com.google.android.libraries.cast.companionlibrary.utils.FetchBitmapTask; import com.google.android.libraries.cast.companionlibrary.utils.LogUtils; import com.google.android.libraries.cast.companionlibrary.utils.Utils; import com.google.android.libraries.cast.companionlibrary.widgets.IMiniController; import com.google.android.libraries.cast.companionlibrary.widgets.MiniController; import com.google.android.libraries.cast.companionlibrary.widgets.MiniController.OnMiniControllerChangedListener; import com.google.android.libraries.cast.companionlibrary.widgets.ProgressWatcher; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.PendingIntent; import android.app.Service; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.res.Resources.NotFoundException; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Point; import android.media.AudioManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.preference.PreferenceScreen; import android.support.annotation.NonNull; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.support.v7.media.MediaRouter.RouteInfo; import android.text.TextUtils; import android.view.KeyEvent; import android.view.View; import android.view.accessibility.CaptioningManager; import org.json.JSONObject; import java.io.IOException; import java.util.Collections; import java.util.HashSet; 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.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; /** * 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 #initialize(android.content.Context, CastConfiguration)} in the Application's * {@code onCreate()} method. All configurable parameters are encapsulates in the * {@link CastConfiguration} parameter. To access the (singleton) instance of this class, clients * need to call {@link #getInstance()}. * <p>Callers can add {@link MiniController} components to their application pages by adding the * corresponding widget to their layout xml and then calling {@code }addMiniController()}. 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 com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumer}). * 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 VideoCastConsumerImpl}) 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. To learn more about this library, please read * the documentation that is distributed as part of this library. * * @see CastConfiguration */ public class VideoCastManager extends BaseCastManager implements OnMiniControllerChangedListener, OnFailedListener { private static final String TAG = LogUtils.makeLogTag(VideoCastManager.class); public static final String EXTRA_HAS_AUTH = "hasAuth"; public static final String EXTRA_MEDIA = "media"; public static final String EXTRA_START_POINT = "startPoint"; public static final String EXTRA_SHOULD_START = "shouldStart"; public static final String EXTRA_CUSTOM_DATA = "customData"; public static final Class<?> DEFAULT_TARGET_ACTIVITY = VideoCastControllerActivity.class; public static final double DEFAULT_VOLUME_STEP = 0.05; private static final long PROGRESS_UPDATE_INTERVAL_MS = TimeUnit.SECONDS.toMillis(1); public static final long DEFAULT_LIVE_STREAM_DURATION_MS = TimeUnit.HOURS.toMillis(2); public static final String PREFS_KEY_START_ACTIVITY = "ccl-start-cast-activity"; private Class<? extends Service> mNotificationServiceClass; private double mVolumeStep = DEFAULT_VOLUME_STEP; private TracksPreferenceManager mTrackManager; private MediaQueue mMediaQueue; private MediaStatus mMediaStatus; private FetchBitmapTask mLockScreenFetchTask; private FetchBitmapTask mMediaSessionIconFetchTask; /** * Volume can be controlled at two different layers, one is at the "stream" level and one at * the "device" level. <code>VolumeType</code> encapsulates these two types. * * @see {@link #setVolumeType} */ public enum VolumeType { STREAM, DEVICE } private static VideoCastManager sInstance; private Class<?> mTargetActivity; private final Set<IMiniController> mMiniControllers = Collections .synchronizedSet(new HashSet<IMiniController>()); private AudioManager mAudioManager; private RemoteMediaPlayer mRemoteMediaPlayer; private MediaSessionCompat mMediaSessionCompat; private VolumeType mVolumeType = VolumeType.DEVICE; private int mState = MediaStatus.PLAYER_STATE_IDLE; private int mIdleReason; private String mDataNamespace; private Cast.MessageReceivedCallback mDataChannel; private final Set<VideoCastConsumer> mVideoConsumers = new CopyOnWriteArraySet<>(); private final Set<OnTracksSelectedListener> mTracksSelectedListeners = new CopyOnWriteArraySet<>(); private final Set<ProgressWatcher> mProgressWatchers = new CopyOnWriteArraySet<>(); private MediaAuthService mAuthService; private long mLiveStreamDuration = DEFAULT_LIVE_STREAM_DURATION_MS; private MediaQueueItem mPreLoadingItem; private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private ScheduledFuture<?> mProgressHandler; 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 VideoCastManager() { } protected VideoCastManager(Context context, CastConfiguration castConfiguration) { super(context, castConfiguration); LOGD(TAG, "VideoCastManager is instantiated"); mDataNamespace = castConfiguration.getNamespaces() == null ? null : castConfiguration.getNamespaces().get(0); Class<?> targetActivity = castConfiguration.getTargetActivity(); if (targetActivity == null) { targetActivity = DEFAULT_TARGET_ACTIVITY; } mTargetActivity = targetActivity; mPreferenceAccessor.saveStringToPreference(PREFS_KEY_CAST_ACTIVITY_NAME, mTargetActivity.getName()); if (!TextUtils.isEmpty(mDataNamespace)) { mPreferenceAccessor.saveStringToPreference(PREFS_KEY_CAST_CUSTOM_DATA_NAMESPACE, mDataNamespace); } mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); mNotificationServiceClass = castConfiguration.getCustomNotificationService(); if (mNotificationServiceClass == null) { mNotificationServiceClass = VideoCastNotificationService.class; } } /** * Returns the notification class, whether it is the default one or the one configured by the * client. */ public final Class<? extends Service> getNotificationServiceClass() { return mNotificationServiceClass; } public static synchronized VideoCastManager initialize(Context context, CastConfiguration castConfiguration) { if (sInstance == null) { LOGD(TAG, "New instance of VideoCastManager is created"); if (ConnectionResult.SUCCESS != GooglePlayServicesUtil .isGooglePlayServicesAvailable(context)) { String msg = "Couldn't find the appropriate version of Google Play Services"; LOGE(TAG, msg); } sInstance = new VideoCastManager(context, castConfiguration); } sInstance.setupTrackManager(); return sInstance; } /** * 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 VideoCastManager getInstance() { if (sInstance == null) { String msg = "No VideoCastManager instance was found, did you forget to initialize it?"; LOGE(TAG, msg); throw new IllegalStateException(msg); } return sInstance; } protected void setupTrackManager() { if (isFeatureEnabled(CastConfiguration.FEATURE_CAPTIONS_PREFERENCE)) { mTrackManager = new TracksPreferenceManager(mContext.getApplicationContext()); registerCaptionListener(mContext.getApplicationContext()); } } /** * Updates the information and state of a MiniController. * * @throws TransientNetworkDisconnectionException * @throws NoConnectionException */ private void updateMiniController(IMiniController controller) throws TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); checkRemoteMediaPlayerAvailable(); if (mRemoteMediaPlayer.getStreamDuration() > 0 || isRemoteStreamLive()) { MediaInfo mediaInfo = getRemoteMediaInformation(); MediaMetadata mm = mediaInfo.getMetadata(); controller.setStreamType(mediaInfo.getStreamType()); controller.setPlaybackStatus(mState, mIdleReason); controller.setSubtitle(mContext.getResources().getString(R.string.ccl_casting_to_device, mDeviceName)); controller.setTitle(mm.getString(MediaMetadata.KEY_TITLE)); controller.setIcon(Utils.getImageUri(mediaInfo, 0)); } } /* * Updates the information and state of all MiniControllers */ private void updateMiniControllers() { synchronized (mMiniControllers) { for (final IMiniController controller : mMiniControllers) { try { updateMiniController(controller); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "updateMiniControllers() Failed to update mini controller", e); } } } } /* * (non-Javadoc) * @see com.google.android.libraries.cast.companionlibrary.widgets.MiniController. * OnMiniControllerChangedListener#onPlayPauseClicked(android.view.View) * @throws TransientNetworkDisconnectionException * @throws NoConnectionException * @throws CastException */ @Override public void onPlayPauseClicked(View v) throws CastException, TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); if (mState == MediaStatus.PLAYER_STATE_PLAYING) { pause(); } else { boolean isLive = isRemoteStreamLive(); if ((mState == MediaStatus.PLAYER_STATE_PAUSED && !isLive) || (mState == MediaStatus.PLAYER_STATE_IDLE && isLive)) { play(); } } } /* * (non-Javadoc) * @see com.google.android.libraries.cast.companionlibrary.widgets.MiniController. * OnMiniControllerChangedListener #onTargetActivityInvoked(android.content.Context) */ @Override public void onTargetActivityInvoked(Context context) throws TransientNetworkDisconnectionException, NoConnectionException { Intent intent = new Intent(context, mTargetActivity); intent.putExtra(EXTRA_MEDIA, Utils.mediaInfoToBundle(getRemoteMediaInformation())); context.startActivity(intent); } @Override public void onUpcomingPlayClicked(View view, MediaQueueItem upcomingItem) { for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onUpcomingPlayClicked(view, upcomingItem); } } @Override public void onUpcomingStopClicked(View view, MediaQueueItem upcomingItem) { for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onUpcomingStopClicked(view, upcomingItem); } } /** * Updates the visibility of the mini controllers. In most cases, clients do not need to use * this as the {@link VideoCastManager} handles the visibility. * * @param visible If {@link true}, mini controllers wil be visible; hidden otherwise. */ public void updateMiniControllersVisibility(boolean visible) { LOGD(TAG, "updateMiniControllersVisibility() reached with visibility: " + visible); synchronized (mMiniControllers) { for (IMiniController controller : mMiniControllers) { controller.setVisibility(visible ? View.VISIBLE : View.GONE); } } } public void updateMiniControllersVisibilityForUpcoming(MediaQueueItem item) { synchronized (mMiniControllers) { for (IMiniController controller : mMiniControllers) { controller.setUpcomingItem(item); controller.setUpcomingVisibility(item != null); } } } /** * Sets an internal flag that is used to disambiguate the two cases that the * {@code VideoCastControllerActivity} is started programmatically or through the system (say, * after configuration change or from recent history). */ private void setFlagForStartCastControllerActivity() { mPreferenceAccessor.saveBooleanToPreference(PREFS_KEY_START_ACTIVITY, true); } /** * Launches the VideoCastControllerActivity that provides a default Cast Player page. * * @param context The context to use for starting the activity * @param mediaWrapper a bundle wrapper for the media that is or will be casted * @param position Starting point, in milliseconds, of the media playback * @param shouldStart indicates if the remote playback should start after launching the new * page * @param customData Optional {@link JSONObject} */ public void startVideoCastControllerActivity(Context context, Bundle mediaWrapper, int position, boolean shouldStart, JSONObject customData) { Intent intent = new Intent(context, VideoCastControllerActivity.class); intent.putExtra(EXTRA_MEDIA, mediaWrapper); intent.putExtra(EXTRA_START_POINT, position); intent.putExtra(EXTRA_SHOULD_START, shouldStart); if (customData != null) { intent.putExtra(EXTRA_CUSTOM_DATA, customData.toString()); } setFlagForStartCastControllerActivity(); context.startActivity(intent); } /** * Launches the {@link VideoCastControllerActivity} that provides a default Cast Player page. * * @param context The context to use for starting the activity * @param mediaWrapper A bundle wrapper for the media that is or will be casted * @param position Starting point, in milliseconds, of the media playback * @param shouldStart Indicates if the remote playback should start after launching the new * page */ public void startVideoCastControllerActivity(Context context, Bundle mediaWrapper, int position, boolean shouldStart) { startVideoCastControllerActivity(context, mediaWrapper, position, shouldStart, null); } /** * Launches the {@link VideoCastControllerActivity} that provides a default Cast Player page. * This variation should be used when an * {@link com.google.android.libraries.cast.companionlibrary.cast.player.MediaAuthService} * needs to be used. */ public void startVideoCastControllerActivity(Context context, MediaAuthService authService) { if (authService != null) { mAuthService = authService; Intent intent = new Intent(context, VideoCastControllerActivity.class); intent.putExtra(EXTRA_HAS_AUTH, true); setFlagForStartCastControllerActivity(); context.startActivity(intent); } } /** * Launches the {@link VideoCastControllerActivity} that provides a default Cast Player page. * * @param context The context to use for starting the activity * @param mediaInfo The media that is or will be casted * @param position Starting point, in milliseconds, of the media playback * @param shouldStart Indicates if the remote playback should start after launching the new page */ public void startVideoCastControllerActivity(Context context, MediaInfo mediaInfo, int position, boolean shouldStart) { startVideoCastControllerActivity(context, Utils.mediaInfoToBundle(mediaInfo), position, shouldStart); } /** * Returns the instance of * {@link com.google.android.libraries.cast.companionlibrary.cast.player.MediaAuthService}, * or null if there is no such instance. */ public MediaAuthService getMediaAuthService() { return mAuthService; } /** * Sets the * {@link com.google.android.libraries.cast.companionlibrary.cast.player.MediaAuthService}. */ public void setMediaAuthService(MediaAuthService authService) { mAuthService = authService; } /** * Removes the pointer to the * {@link com.google.android.libraries.cast.companionlibrary.cast.player.MediaAuthService} to * avoid any leak. */ public void removeMediaAuthService() { mAuthService = null; } /** * 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 mRemoteMediaPlayer; } /** * 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 helper method to determine if, given a player state and an idle reason (if the state is * idle) will warrant having a UI for remote presentation of the remote content. * * @throws TransientNetworkDisconnectionException * @throws NoConnectionException */ public boolean shouldRemoteUiBeVisible(int state, int idleReason) throws TransientNetworkDisconnectionException, NoConnectionException { switch (state) { case MediaStatus.PLAYER_STATE_PLAYING: case MediaStatus.PLAYER_STATE_PAUSED: case MediaStatus.PLAYER_STATE_BUFFERING: return true; case MediaStatus.PLAYER_STATE_IDLE: if (isRemoteStreamLive() && (idleReason == MediaStatus.IDLE_REASON_CANCELED)) { // we have a live stream and we have "stopped/paused" it return true; } else { // if we have not reached the end of queue, return true otherwise return false return mMediaStatus != null && (mMediaStatus.getLoadingItemId() != MediaQueueItem.INVALID_ITEM_ID); } default: } return false; } /* * A simple check to make sure mRemoteMediaPlayer is not null */ private void checkRemoteMediaPlayerAvailable() throws NoConnectionException { if (mRemoteMediaPlayer == null) { throw new NoConnectionException(); } } /** * Sets the type of volume. Most applications should use {@code VolumeType.DEVICE} (which is * the default value) but in rare cases, an application can set the type to {@code * VolumeType.STREAM} */ public final void setVolumeType(VolumeType volumeType) { mVolumeType = volumeType; } /** * Returns the url for the movie 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 (mRemoteMediaPlayer != null && mRemoteMediaPlayer.getMediaInfo() != null) { MediaInfo info = mRemoteMediaPlayer.getMediaInfo(); mRemoteMediaPlayer.getMediaStatus().getPlayerState(); return info.getContentId(); } throw new NoConnectionException(); } /** * Indicates if the remote movie is currently playing (or buffering). * * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public boolean isRemoteMediaPlaying() throws TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); return mState == MediaStatus.PLAYER_STATE_BUFFERING || mState == 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 mState == 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 mRemoteMediaPlayer.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 getVolume() throws TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); if (mVolumeType == VolumeType.STREAM) { checkRemoteMediaPlayerAvailable(); return mRemoteMediaPlayer.getMediaStatus().getStreamVolume(); } return getDeviceVolume(); } /** * Sets the volume. It internally determines if this should be done for <code>stream</code> or * <code>device</code> volume. * * @param volume Should be a value between 0 and 1, inclusive. * @throws NoConnectionException * @throws TransientNetworkDisconnectionException * @throws CastException If setting system volume fails * * @see {link #setVolumeType()} */ public void setVolume(double volume) throws CastException, TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); if (volume > 1.0) { volume = 1.0; } else if (volume < 0) { volume = 0.0; } if (mVolumeType == VolumeType.STREAM) { checkRemoteMediaPlayerAvailable(); mRemoteMediaPlayer.setStreamVolume(mApiClient, volume).setResultCallback( new ResultCallback<RemoteMediaPlayer.MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { onFailed(R.string.ccl_failed_setting_volume, result.getStatus().getStatusCode()); } } } ); } else { setDeviceVolume(volume); } } /** * Increments (or decrements) the volume by the given amount. It internally determines if this * should be done for stream or device volume. * * @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 * @throws CastException */ public void adjustVolume(double delta) throws CastException, TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); double vol = getVolume() + delta; if (vol > 1) { vol = 1; } else if (vol < 0) { vol = 0; } setVolume(vol); } /** * Increments or decrements volume by <code>delta</code> if {@code delta < 0} or * {@code delta > 0}, respectively. Note that the volume range is between 0 and {@code * RouteInfo.getVolumeMax()}. */ public void updateVolume(int delta) { RouteInfo info = mMediaRouter.getSelectedRoute(); info.requestUpdateVolume(delta); } /** * Returns <code>true</code> if remote device is muted. It internally determines if this should * be done for <code>stream</code> or <code>device</code> volume. * * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public boolean isMute() throws TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); if (mVolumeType == VolumeType.STREAM) { checkRemoteMediaPlayerAvailable(); return mRemoteMediaPlayer.getMediaStatus().isMute(); } else { return isDeviceMute(); } } /** * Mutes or un-mutes the volume. It internally determines if this should be done for * <code>stream</code> or <code>device</code> volume. * * @throws CastException * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public void setMute(boolean mute) throws CastException, TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); if (mVolumeType == VolumeType.STREAM) { checkRemoteMediaPlayerAvailable(); mRemoteMediaPlayer.setStreamMute(mApiClient, mute); } else { setDeviceMute(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 mRemoteMediaPlayer.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 (mRemoteMediaPlayer == null) { return -1; } return isRemoteStreamLive() ? mLiveStreamDuration : mRemoteMediaPlayer.getStreamDuration() - mRemoteMediaPlayer.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 mRemoteMediaPlayer.getApproximateStreamPosition(); } /* * Starts a service that can last beyond the lifetime of the application to provide * notifications. The service brings itself down when needed. The service will be started only * if the notification feature has been enabled during the initialization. * @see {@link BaseCastManager#enableFeatures()} */ private boolean startNotificationService() { if (!isFeatureEnabled(CastConfiguration.FEATURE_NOTIFICATION)) { return true; } LOGD(TAG, "startNotificationService()"); Intent service = new Intent(mContext, mNotificationServiceClass); service.setPackage(mContext.getPackageName()); service.setAction(VideoCastNotificationService.ACTION_VISIBILITY); service.putExtra(VideoCastNotificationService.NOTIFICATION_VISIBILITY, !mUiVisible); return mContext.startService(service) != null; } private void stopNotificationService() { if (!isFeatureEnabled(CastConfiguration.FEATURE_NOTIFICATION)) { return; } if (mContext != null) { mContext.stopService(new Intent(mContext, mNotificationServiceClass)); } } private void onApplicationDisconnected(int errorCode) { LOGD(TAG, "onApplicationDisconnected() reached with error code: " + errorCode); mApplicationErrorCode = errorCode; updateMediaSession(false); if (mMediaSessionCompat != null && isFeatureEnabled(CastConfiguration.FEATURE_LOCKSCREEN)) { mMediaRouter.setMediaSessionCompat(null); } for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onApplicationDisconnected(errorCode); } if (mMediaRouter != null) { LOGD(TAG, "onApplicationDisconnected(): Cached RouteInfo: " + getRouteInfo()); LOGD(TAG, "onApplicationDisconnected(): Selected RouteInfo: " + mMediaRouter.getSelectedRoute()); if (getRouteInfo() == null || mMediaRouter.getSelectedRoute().equals(getRouteInfo())) { LOGD(TAG, "onApplicationDisconnected(): Setting route to default"); mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute()); } } onDeviceSelected(null /* CastDevice */, null /* RouteInfo */); updateMiniControllersVisibility(false); stopNotificationService(); } private void onApplicationStatusChanged() { if (!isConnected()) { return; } try { String appStatus = Cast.CastApi.getApplicationStatus(mApiClient); LOGD(TAG, "onApplicationStatusChanged() reached: " + appStatus); for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onApplicationStatusChanged(appStatus); } } catch (IllegalStateException e) { LOGE(TAG, "onApplicationStatusChanged()", e); } } private void onVolumeChanged() { LOGD(TAG, "onVolumeChanged() reached"); double volume; try { volume = getVolume(); boolean isMute = isMute(); for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onVolumeChanged(volume, isMute); } } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "Failed to get volume", e); } } @Override protected void onApplicationConnected(ApplicationMetadata appMetadata, String applicationStatus, String sessionId, boolean wasLaunched) { LOGD(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<RouteInfo> routes = mMediaRouter.getRoutes(); if (routes != null) { String routeId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_ROUTE_ID); for (RouteInfo routeInfo : routes) { if (routeId.equals(routeInfo.getId())) { // found the right route LOGD(TAG, "Found the correct route during reconnection attempt"); mReconnectionStatus = RECONNECTION_STATUS_FINALIZED; mMediaRouter.selectRoute(routeInfo); break; } } } } startNotificationService(); try { attachDataChannel(); attachMediaChannel(); mSessionId = sessionId; // saving device for future retrieval; we only save the last session info mPreferenceAccessor.saveStringToPreference(PREFS_KEY_SESSION_ID, mSessionId); mRemoteMediaPlayer.requestStatus(mApiClient). setResultCallback(new ResultCallback<RemoteMediaPlayer.MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { onFailed(R.string.ccl_failed_status_request, result.getStatus().getStatusCode()); } } }); for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onApplicationConnected(appMetadata, mSessionId, wasLaunched); } } catch (TransientNetworkDisconnectionException e) { LOGE(TAG, "Failed to attach media/data channel due to network issues", e); onFailed(R.string.ccl_failed_no_connection_trans, NO_STATUS_CODE); } catch (NoConnectionException e) { LOGE(TAG, "Failed to attach media/data channel due to network issues", e); onFailed(R.string.ccl_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 (VideoCastConsumer consumer : mVideoConsumers) { consumer.onApplicationStopFailed(errorCode); } } @Override public void onApplicationConnectionFailed(int errorCode) { LOGD(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 (VideoCastConsumer consumer : mVideoConsumers) { consumer.onApplicationConnectionFailed(errorCode); } onDeviceSelected(null /* CastDevice */, null /* RouteInfo */); if (mMediaRouter != null) { LOGD(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 { LOGD(TAG, "loadMedia"); checkConnectivity(); if (media == null) { return; } if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to load a video with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer.load(mApiClient, media, autoPlay, position, activeTracks, customData) .setResultCallback(new ResultCallback<RemoteMediaPlayer.MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { LOGD(TAG, "queueLoad"); checkConnectivity(); if (items == null || items.length == 0) { return; } if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to queue one or more videos with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queueLoad(mApiClient, items, startIndex, repeatMode, customData) .setResultCallback(new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { LOGD(TAG, "queueInsertItems"); checkConnectivity(); if (itemsToInsert == null || itemsToInsert.length == 0) { throw new IllegalArgumentException("items cannot be empty or null"); } if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to insert into queue with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queueInsertItems(mApiClient, itemsToInsert, insertBeforeItemId, customData) .setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to update the queue with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queueUpdateItems(mApiClient, itemsToUpdate, customData).setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { LOGD(TAG, "queueUpdateItems() " + result.getStatus() + result.getStatus() .isSuccess()); for (VideoCastConsumer consumer : mVideoConsumers) { 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 (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to jump in a queue with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queueJumpToItem(mApiClient, itemId, customData).setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { LOGD(TAG, "queueRemoveItems"); checkConnectivity(); if (itemIdsToRemove == null || itemIdsToRemove.length == 0) { throw new IllegalArgumentException("itemIds cannot be empty or null"); } if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to remove items from queue with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queueRemoveItems(mApiClient, itemIdsToRemove, customData).setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { LOGD(TAG, "queueRemoveItem"); checkConnectivity(); if (itemId == MediaQueueItem.INVALID_ITEM_ID) { throw new IllegalArgumentException("itemId is invalid"); } if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to remove an item from queue with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queueRemoveItem(mApiClient, itemId, customData).setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { LOGD(TAG, "queueReorderItems"); checkConnectivity(); if (itemIdsToReorder == null || itemIdsToReorder.length == 0) { throw new IllegalArgumentException("itemIdsToReorder cannot be empty or null"); } if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to reorder items in a queue with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queueReorderItems(mApiClient, itemIdsToReorder, insertBeforeItemId, customData) .setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { mRemoteMediaPlayer .queueMoveItemToNewIndex(mApiClient, itemId, newIndex, customData) .setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { mRemoteMediaPlayer .queueAppendItem(mApiClient, item, customData) .setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to update the queue with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queueNext(mApiClient, customData).setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to update the queue with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queuePrev(mApiClient, customData).setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { for (VideoCastConsumer consumer : mVideoConsumers) { 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 { checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(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"); } mRemoteMediaPlayer.queueInsertItems(mApiClient, new MediaQueueItem[]{item}, insertBeforeItemId, customData).setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (result.getStatus().isSuccess()) { try { queuePrev(customData); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "queuePrev() Failed to skip to previous", e); } } for (VideoCastConsumer consumer : mVideoConsumers) { 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 { checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to update the queue with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer .queueSetRepeatMode(mApiClient, repeatMode, customData).setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { LOGD(TAG, "Failed with status: " + result.getStatus()); } for (VideoCastConsumer consumer : mVideoConsumers) { 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(); LOGD(TAG, "attempting to play media at position " + position + " seconds"); if (mRemoteMediaPlayer == null) { LOGE(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 { LOGD(TAG, "play(customData)"); checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to play a video with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer.play(mApiClient, customData) .setResultCallback(new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { onFailed(R.string.ccl_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 { LOGD(TAG, "stop()"); checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to stop a stream with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer.stop(mApiClient, customData).setResultCallback( new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { onFailed(R.string.ccl_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 { LOGD(TAG, "attempting to pause media"); checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to pause a video with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer.pause(mApiClient, customData) .setResultCallback(new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { onFailed(R.string.ccl_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 { LOGD(TAG, "attempting to seek media"); checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to seek a video with no active media session"); throw new NoConnectionException(); } mRemoteMediaPlayer.seek(mApiClient, position, RemoteMediaPlayer.RESUME_STATE_UNCHANGED). setResultCallback(new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { onFailed(R.string.ccl_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 { LOGD(TAG, "forward(): attempting to forward media by " + lengthInMillis); checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to seek a video with no active media session"); throw new NoConnectionException(); } long position = mRemoteMediaPlayer.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 { LOGD(TAG, "attempting to seek media"); checkConnectivity(); if (mRemoteMediaPlayer == null) { LOGE(TAG, "Trying to seekAndPlay a video with no active media session"); throw new NoConnectionException(); } ResultCallback<MediaChannelResult> resultCallback = new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { onFailed(R.string.ccl_failed_seek, result.getStatus().getStatusCode()); } } }; mRemoteMediaPlayer.seek(mApiClient, position, RemoteMediaPlayer.RESUME_STATE_PLAY).setResultCallback(resultCallback); } /** * Toggles the playback of the movie. * * @throws CastException * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public void togglePlayback() throws CastException, TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); boolean isPlaying = isRemoteMediaPlaying(); if (isPlaying) { pause(); } else { if (mState == MediaStatus.PLAYER_STATE_IDLE && mIdleReason == MediaStatus.IDLE_REASON_FINISHED) { loadMedia(getRemoteMediaInformation(), true, 0); } else { play(); } } } private void attachMediaChannel() throws TransientNetworkDisconnectionException, NoConnectionException { LOGD(TAG, "attachMediaChannel()"); checkConnectivity(); if (mRemoteMediaPlayer == null) { mRemoteMediaPlayer = new RemoteMediaPlayer(); mRemoteMediaPlayer.setOnStatusUpdatedListener( new RemoteMediaPlayer.OnStatusUpdatedListener() { @Override public void onStatusUpdated() { LOGD(TAG, "RemoteMediaPlayer::onStatusUpdated() is reached"); VideoCastManager.this.onRemoteMediaPlayerStatusUpdated(); } } ); mRemoteMediaPlayer.setOnPreloadStatusUpdatedListener( new RemoteMediaPlayer.OnPreloadStatusUpdatedListener() { @Override public void onPreloadStatusUpdated() { LOGD(TAG, "RemoteMediaPlayer::onPreloadStatusUpdated() is " + "reached"); VideoCastManager.this.onRemoteMediaPreloadStatusUpdated(); } }); mRemoteMediaPlayer.setOnMetadataUpdatedListener( new RemoteMediaPlayer.OnMetadataUpdatedListener() { @Override public void onMetadataUpdated() { LOGD(TAG, "RemoteMediaPlayer::onMetadataUpdated() is reached"); VideoCastManager.this.onRemoteMediaPlayerMetadataUpdated(); } } ); mRemoteMediaPlayer.setOnQueueStatusUpdatedListener( new RemoteMediaPlayer.OnQueueStatusUpdatedListener() { @Override public void onQueueStatusUpdated() { LOGD(TAG, "RemoteMediaPlayer::onQueueStatusUpdated() is reached"); mMediaStatus = mRemoteMediaPlayer != null ? mRemoteMediaPlayer .getMediaStatus() : null; if (mMediaStatus != null && mMediaStatus.getQueueItems() != null) { List<MediaQueueItem> queueItems = mMediaStatus .getQueueItems(); int itemId = mMediaStatus.getCurrentItemId(); MediaQueueItem item = mMediaStatus .getQueueItemById(itemId); int repeatMode = mMediaStatus.getQueueRepeatMode(); onQueueUpdated(queueItems, item, repeatMode, false); } else { onQueueUpdated(null, null, MediaStatus.REPEAT_MODE_REPEAT_OFF, false); } } }); } try { LOGD(TAG, "Registering MediaChannel namespace"); Cast.CastApi.setMessageReceivedCallbacks(mApiClient, mRemoteMediaPlayer.getNamespace(), mRemoteMediaPlayer); } catch (IOException | IllegalStateException e) { LOGE(TAG, "attachMediaChannel()", e); } setUpMediaSession(null); } private void reattachMediaChannel() { if (mRemoteMediaPlayer != null && mApiClient != null) { try { LOGD(TAG, "Registering MediaChannel namespace"); Cast.CastApi.setMessageReceivedCallbacks(mApiClient, mRemoteMediaPlayer.getNamespace(), mRemoteMediaPlayer); } catch (IOException | IllegalStateException e) { LOGE(TAG, "reattachMediaChannel()", e); } } } private void detachMediaChannel() { LOGD(TAG, "trying to detach media channel"); if (mRemoteMediaPlayer != null) { try { Cast.CastApi.removeMessageReceivedCallbacks(mApiClient, mRemoteMediaPlayer.getNamespace()); } catch (IOException | IllegalStateException e) { LOGE(TAG, "detachMediaChannel()", e); } mRemoteMediaPlayer = 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 mState; } /** * Returns the latest retrieved value for the {@link MediaStatus}. This value is updated * whenever the onStatusUpdated callback is called. */ public final MediaStatus getMediaStatus() { return mMediaStatus; } /** * 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 mIdleReason; } /* * If a data namespace was provided when initializing this class, we set things up for a data * channel * * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ private void attachDataChannel() throws TransientNetworkDisconnectionException, NoConnectionException { if (TextUtils.isEmpty(mDataNamespace)) { return; } if (mDataChannel != null) { return; } checkConnectivity(); mDataChannel = new MessageReceivedCallback() { @Override public void onMessageReceived(CastDevice castDevice, String namespace, String message) { for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onDataMessageReceived(message); } } }; try { Cast.CastApi.setMessageReceivedCallbacks(mApiClient, mDataNamespace, mDataChannel); } catch (IOException | IllegalStateException e) { LOGE(TAG, "attachDataChannel()", e); } } private void reattachDataChannel() { if (!TextUtils.isEmpty(mDataNamespace) && mDataChannel != null) { try { Cast.CastApi.setMessageReceivedCallbacks(mApiClient, mDataNamespace, mDataChannel); } catch (IOException | IllegalStateException e) { LOGE(TAG, "reattachDataChannel()", e); } } } private void onMessageSendFailed(int errorCode) { for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onDataMessageSendFailed(errorCode); } } /** * Sends the <code>message</code> on the data channel for the namespace that was provided * during the initialization of this class. If <code>messageId > 0</code>, then it has to be * a unique identifier for the message; this id will be returned if an error occurs. If * <code>messageId == 0</code>, then an auto-generated unique identifier will be created and * returned for the message. * * @throws IllegalStateException If the namespace is empty or null * @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 void sendDataMessage(String message) throws TransientNetworkDisconnectionException, NoConnectionException { if (TextUtils.isEmpty(mDataNamespace)) { throw new IllegalStateException("No Data Namespace is configured"); } checkConnectivity(); Cast.CastApi.sendMessage(mApiClient, mDataNamespace, message) .setResultCallback(new ResultCallback<Status>() { @Override public void onResult(Status result) { if (!result.isSuccess()) { VideoCastManager.this.onMessageSendFailed(result.getStatusCode()); } } }); } /** * Remove the custom data channel, if any. It returns <code>true</code> if it succeeds * otherwise if it encounters an error or if no connection exists or if no custom data channel * exists, then it returns <code>false</code> */ public boolean removeDataChannel() { if (TextUtils.isEmpty(mDataNamespace)) { return false; } try { if (mApiClient != null) { Cast.CastApi.removeMessageReceivedCallbacks(mApiClient, mDataNamespace); } mDataChannel = null; mPreferenceAccessor.saveStringToPreference(PREFS_KEY_CAST_CUSTOM_DATA_NAMESPACE, null); return true; } catch (IOException | IllegalStateException e) { LOGE(TAG, "removeDataChannel() failed to remove namespace " + mDataNamespace, e); } return false; } /* * This is called by onStatusUpdated() of the RemoteMediaPlayer */ private void onRemoteMediaPlayerStatusUpdated() { LOGD(TAG, "onRemoteMediaPlayerStatusUpdated() reached"); if (mApiClient == null || mRemoteMediaPlayer == null || mRemoteMediaPlayer.getMediaStatus() == null) { LOGD(TAG, "mApiClient or mRemoteMediaPlayer is null, so will not proceed"); return; } mMediaStatus = mRemoteMediaPlayer.getMediaStatus(); List<MediaQueueItem> queueItems = mMediaStatus.getQueueItems(); if (queueItems != null) { int itemId = mMediaStatus.getCurrentItemId(); MediaQueueItem item = mMediaStatus.getQueueItemById(itemId); int repeatMode = mMediaStatus.getQueueRepeatMode(); onQueueUpdated(queueItems, item, repeatMode, false); } else { onQueueUpdated(null, null, MediaStatus.REPEAT_MODE_REPEAT_OFF, false); } mState = mMediaStatus.getPlayerState(); mIdleReason = mMediaStatus.getIdleReason(); try { double volume = getVolume(); boolean isMute = isMute(); boolean makeUiHidden = false; if (mState == MediaStatus.PLAYER_STATE_PLAYING) { LOGD(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = playing"); updateMediaSession(true); long mediaDurationLeft = getMediaTimeRemaining(); startReconnectionService(mediaDurationLeft); startNotificationService(); } else if (mState == MediaStatus.PLAYER_STATE_PAUSED) { LOGD(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = paused"); updateMediaSession(false); startNotificationService(); } else if (mState == MediaStatus.PLAYER_STATE_IDLE) { LOGD(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = IDLE with reason: " + mIdleReason ); updateMediaSession(false); switch (mIdleReason) { case MediaStatus.IDLE_REASON_FINISHED: if (mMediaStatus.getLoadingItemId() == MediaQueueItem.INVALID_ITEM_ID) { // we have reached the end of queue clearMediaSession(); makeUiHidden = true; } break; case MediaStatus.IDLE_REASON_ERROR: // something bad happened on the cast device LOGD(TAG, "onRemoteMediaPlayerStatusUpdated(): IDLE reason = ERROR"); makeUiHidden = true; clearMediaSession(); onFailed(R.string.ccl_failed_receiver_player_error, NO_STATUS_CODE); break; case MediaStatus.IDLE_REASON_CANCELED: LOGD(TAG, "onRemoteMediaPlayerStatusUpdated(): IDLE reason = CANCELLED"); makeUiHidden = !isRemoteStreamLive(); break; case MediaStatus.IDLE_REASON_INTERRUPTED: if (mMediaStatus.getLoadingItemId() == MediaQueueItem.INVALID_ITEM_ID) { // we have reached the end of queue clearMediaSession(); makeUiHidden = true; } break; default: LOGE(TAG, "onRemoteMediaPlayerStatusUpdated(): Unexpected Idle Reason " + mIdleReason); } } else if (mState == MediaStatus.PLAYER_STATE_BUFFERING) { LOGD(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = buffering"); } else { LOGD(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = unknown"); makeUiHidden = true; } if (makeUiHidden) { stopReconnectionService(); stopNotificationService(); } updateMiniControllersVisibility(!makeUiHidden); updateMiniControllers(); for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onRemoteMediaPlayerStatusUpdated(); consumer.onVolumeChanged(volume, isMute); } } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "Failed to get volume state due to network issues", e); } } private void onRemoteMediaPreloadStatusUpdated() { MediaQueueItem item = null; mMediaStatus = mRemoteMediaPlayer != null ? mRemoteMediaPlayer .getMediaStatus() : null; if (mMediaStatus != null) { item = mMediaStatus.getQueueItemById(mMediaStatus.getPreloadedItemId()); } mPreLoadingItem = item; updateMiniControllersVisibilityForUpcoming(item); LOGD(TAG, "onRemoteMediaPreloadStatusUpdated() " + item); for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onRemoteMediaPreloadStatusUpdated(item); } } public MediaQueueItem getPreLoadingItem() { return mPreLoadingItem; } /* * This is called by onQueueStatusUpdated() of RemoteMediaPlayer */ private void onQueueUpdated(List<MediaQueueItem> queueItems, MediaQueueItem item, int repeatMode, boolean shuffle) { LOGD(TAG, "onQueueUpdated() reached"); LOGD(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) { mMediaQueue = new MediaQueue(new CopyOnWriteArrayList<>(queueItems), item, shuffle, repeatMode); } else { mMediaQueue = new MediaQueue(new CopyOnWriteArrayList<MediaQueueItem>(), null, false, MediaStatus.REPEAT_MODE_REPEAT_OFF); } for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onMediaQueueUpdated(queueItems, item, repeatMode, shuffle); } } /* * This is called by onMetadataUpdated() of RemoteMediaPlayer */ public void onRemoteMediaPlayerMetadataUpdated() { LOGD(TAG, "onRemoteMediaPlayerMetadataUpdated() reached"); updateMediaSessionMetadata(); for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onRemoteMediaPlayerMetadataUpdated(); } try { updateLockScreenImage(getRemoteMediaInformation()); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "Failed to update lock screen metadata due to a network issue", e); } } /** * Returns the Media Session Token. If there is no media session, it returns {@code null} */ public MediaSessionCompat.Token getMediaSessionCompatToken() { return mMediaSessionCompat == null ? null : mMediaSessionCompat.getSessionToken(); } /* * Sets up the {@link MediaSessionCompat} for this application. It also handles the audio * focus. */ @SuppressLint("InlinedApi") private void setUpMediaSession(final MediaInfo info) { if (!isFeatureEnabled(CastConfiguration.FEATURE_LOCKSCREEN)) { return; } if (mMediaSessionCompat == null) { ComponentName mediaEventReceiver = new ComponentName(mContext, VideoIntentReceiver.class.getName()); mMediaSessionCompat = new MediaSessionCompat(mContext, "TAG", mediaEventReceiver, null); mMediaSessionCompat.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); mMediaSessionCompat.setActive(true); mMediaSessionCompat.setCallback(new MediaSessionCompat.Callback() { @Override public boolean onMediaButtonEvent(Intent mediaButtonIntent) { KeyEvent keyEvent = mediaButtonIntent .getParcelableExtra(Intent.EXTRA_KEY_EVENT); if (keyEvent != null && (keyEvent.getKeyCode() == KeyEvent.KEYCODE_MEDIA_PAUSE || keyEvent.getKeyCode() == KeyEvent.KEYCODE_MEDIA_PLAY)) { toggle(); } return true; } @Override public void onPlay() { toggle(); } @Override public void onPause() { toggle(); } private void toggle() { try { togglePlayback(); } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "MediaSessionCompat.Callback(): Failed to toggle playback", e); } } }); } mAudioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK); PendingIntent pi = getCastControllerPendingIntent(); if (pi != null) { mMediaSessionCompat.setSessionActivity(pi); } if (info == null) { mMediaSessionCompat.setPlaybackState(new PlaybackStateCompat.Builder() .setState(PlaybackStateCompat.STATE_NONE, 0, 1.0f).build()); } else { mMediaSessionCompat.setPlaybackState(new PlaybackStateCompat.Builder() .setState(PlaybackStateCompat.STATE_PLAYING, 0, 1.0f) .setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE).build()); } // Update the media session's image updateLockScreenImage(info); // update the media session's metadata updateMediaSessionMetadata(); mMediaRouter.setMediaSessionCompat(mMediaSessionCompat); } /* * Returns a PendingIntent that can open the target activity for controlling the cast experience */ private PendingIntent getCastControllerPendingIntent() { try { Bundle mediaWrapper = Utils.mediaInfoToBundle(getRemoteMediaInformation()); Intent contentIntent = new Intent(mContext, mTargetActivity); contentIntent.putExtra(VideoCastManager.EXTRA_MEDIA, mediaWrapper); return PendingIntent .getActivity(mContext, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "getCastControllerPendingIntent(): Failed to get the remote media information"); } return null; } /* * Updates lock screen image */ private void updateLockScreenImage(final MediaInfo info) { if (info == null) { return; } setBitmapForLockScreen(info); } /* * Sets the appropriate {@link Bitmap} for the right size image for lock screen. In ICS and * JB, the image shown on the lock screen is a small size bitmap but for KitKat, the image is a * full-screen image so we need to separately handle these two cases. */ private void setBitmapForLockScreen(MediaInfo video) { if (video == null || mMediaSessionCompat == null) { return; } Uri imgUrl = null; Bitmap bm = null; List<WebImage> images = video.getMetadata().getImages(); if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { if (images.size() > 1) { imgUrl = images.get(1).getUrl(); } else if (images.size() == 1) { imgUrl = images.get(0).getUrl(); } else if (mContext != null) { // we don't have a url for image so get a placeholder image from resources bm = BitmapFactory.decodeResource(mContext.getResources(), R.drawable.album_art_placeholder_large); } } else if (!images.isEmpty()) { imgUrl = images.get(0).getUrl(); } else { // we don't have a url for image so get a placeholder image from resources bm = BitmapFactory.decodeResource(mContext.getResources(), R.drawable.album_art_placeholder); } if (bm != null) { MediaMetadataCompat currentMetadata = mMediaSessionCompat.getController().getMetadata(); MediaMetadataCompat.Builder newBuilder = currentMetadata == null ? new MediaMetadataCompat.Builder() : new MediaMetadataCompat.Builder(currentMetadata); mMediaSessionCompat.setMetadata(newBuilder .putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bm) .build()); } else { if (mLockScreenFetchTask != null) { mLockScreenFetchTask.cancel(true); } Point screenSize = Utils.getDisplaySize(mContext); mLockScreenFetchTask = new FetchBitmapTask(screenSize.x, screenSize.y, false) { @Override protected void onPostExecute(Bitmap bitmap) { if (bitmap != null && mMediaSessionCompat != null) { MediaMetadataCompat currentMetadata = mMediaSessionCompat.getController() .getMetadata(); MediaMetadataCompat.Builder newBuilder = currentMetadata == null ? new MediaMetadataCompat.Builder() : new MediaMetadataCompat.Builder(currentMetadata); mMediaSessionCompat.setMetadata(newBuilder .putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) .build()); } mLockScreenFetchTask = null; } }; mLockScreenFetchTask.execute(imgUrl); } } /* * Updates the playback status of the Media Session */ @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) private void updateMediaSession(boolean playing) { if (!isFeatureEnabled(CastConfiguration.FEATURE_LOCKSCREEN)) { return; } if (!isConnected()) { return; } try { if ((mMediaSessionCompat == null) && playing) { setUpMediaSession(getRemoteMediaInformation()); } if (mMediaSessionCompat != null) { int playState = isRemoteStreamLive() ? PlaybackStateCompat.STATE_BUFFERING : PlaybackStateCompat.STATE_PLAYING; int state = playing ? playState : PlaybackStateCompat.STATE_PAUSED; PendingIntent pi = getCastControllerPendingIntent(); if (pi != null) { mMediaSessionCompat.setSessionActivity(pi); } mMediaSessionCompat.setPlaybackState(new PlaybackStateCompat.Builder() .setState(state, 0, 1.0f) .setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE).build()); } } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "Failed to set up MediaSessionCompat due to network issues", e); } } /* * On ICS and JB, lock screen metadata is one liner: Title - Album Artist - Album. On KitKat, it * has two lines: Title , Album Artist - Album */ private void updateMediaSessionMetadata() { if ((mMediaSessionCompat == null) || !isFeatureEnabled( CastConfiguration.FEATURE_LOCKSCREEN)) { return; } try { MediaInfo info = getRemoteMediaInformation(); if (info == null) { return; } final MediaMetadata mm = info.getMetadata(); MediaMetadataCompat currentMetadata = mMediaSessionCompat.getController().getMetadata(); MediaMetadataCompat.Builder newBuilder = currentMetadata == null ? new MediaMetadataCompat.Builder() : new MediaMetadataCompat.Builder(currentMetadata); MediaMetadataCompat metadata = newBuilder // used in lock screen for pre-lollipop .putString(MediaMetadataCompat.METADATA_KEY_TITLE, mm.getString(MediaMetadata.KEY_TITLE)) // used in lock screen for pre-lollipop .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, mContext.getResources().getString( R.string.ccl_casting_to_device, getDeviceName())) // used in MediaRouteController dialog .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, mm.getString(MediaMetadata.KEY_TITLE)) // used in MediaRouteController dialog .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, mm.getString(MediaMetadata.KEY_SUBTITLE)) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, info.getStreamDuration()) .build(); mMediaSessionCompat.setMetadata(metadata); Uri iconUri = mm.hasImages() ? mm.getImages().get(0).getUrl() : null; if (iconUri == null) { Bitmap bm = BitmapFactory.decodeResource( mContext.getResources(), R.drawable.album_art_placeholder); mMediaSessionCompat.setMetadata(newBuilder .putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bm) .build()); } else { if (mMediaSessionIconFetchTask != null) { mMediaSessionIconFetchTask.cancel(true); } mMediaSessionIconFetchTask = new FetchBitmapTask() { @Override protected void onPostExecute(Bitmap bitmap) { if (bitmap != null && mMediaSessionCompat != null) { MediaMetadataCompat currentMetadata = mMediaSessionCompat .getController().getMetadata(); MediaMetadataCompat.Builder newBuilder = currentMetadata == null ? new MediaMetadataCompat.Builder() : new MediaMetadataCompat.Builder(currentMetadata); mMediaSessionCompat.setMetadata(newBuilder.putBitmap( MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bitmap).build()); } mMediaSessionIconFetchTask = null; } }; mMediaSessionIconFetchTask.execute(iconUri); } } catch (NotFoundException e) { LOGE(TAG, "Failed to update Media Session due to resource not found", e); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "Failed to update Media Session due to network issues", e); } } /* * Clears Media Session */ public void clearMediaSession() { LOGD(TAG, "clearMediaSession()"); if (isFeatureEnabled(CastConfiguration.FEATURE_LOCKSCREEN)) { if (mLockScreenFetchTask != null) { mLockScreenFetchTask.cancel(true); } if (mMediaSessionIconFetchTask != null) { mMediaSessionIconFetchTask.cancel(true); } mAudioManager.abandonAudioFocus(null); if (mMediaSessionCompat != null) { mMediaSessionCompat.setMetadata(null); PlaybackStateCompat playbackState = new PlaybackStateCompat.Builder() .setState(PlaybackStateCompat.STATE_NONE, 0, 1.0f).build(); mMediaSessionCompat.setPlaybackState(playbackState); mMediaSessionCompat.release(); mMediaSessionCompat.setActive(false); mMediaSessionCompat = null; } } } /** * Registers an * {@link com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumer} * 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 VideoCastConsumerImpl */ public synchronized void addVideoCastConsumer(VideoCastConsumer listener) { if (listener != null) { addBaseCastConsumer(listener); mVideoConsumers.add(listener); LOGD(TAG, "Successfully added the new CastConsumer listener " + listener); } } /** * Unregisters an * {@link com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumer}. */ public synchronized void removeVideoCastConsumer(VideoCastConsumer listener) { if (listener != null) { removeBaseCastConsumer(listener); mVideoConsumers.remove(listener); } } public synchronized void addProgressWatcher(ProgressWatcher watcher) { if (watcher != null) { mProgressWatchers.add(watcher); } } public synchronized void removeProgressWatcher(ProgressWatcher watcher) { if (watcher != null) { mProgressWatchers.remove(watcher); } } /** * Adds a new {@link IMiniController} component. Callers need to provide their own * {@link OnMiniControllerChangedListener}. * * @see {@link #removeMiniController(IMiniController)} */ public void addMiniController(IMiniController miniController, OnMiniControllerChangedListener onChangedListener) { if (miniController != null) { boolean result; synchronized (mMiniControllers) { result = mMiniControllers.add(miniController); } if (result) { miniController.setOnMiniControllerChangedListener(onChangedListener == null ? this : onChangedListener); try { if (isConnected() && isRemoteMediaLoaded()) { updateMiniController(miniController); miniController.setVisibility(View.VISIBLE); } } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "Failed to get the status of media playback on receiver", e); } LOGD(TAG, "Successfully added the new MiniController " + miniController); } else { LOGD(TAG, "Attempting to adding " + miniController + " but it was already " + "registered, skipping this step"); } if (mProgressHandler == null || mProgressHandler.isCancelled()) { restartProgressTimer(); } } } /** * Adds a new {@link IMiniController} component and assigns {@link VideoCastManager} as the * {@link OnMiniControllerChangedListener} for this component. */ public void addMiniController(IMiniController miniController) { addMiniController(miniController, null); } /** * Removes a {@link IMiniController} listener from the list of listeners. */ public void removeMiniController(IMiniController listener) { if (listener != null) { listener.setOnMiniControllerChangedListener(null); synchronized (mMiniControllers) { mMiniControllers.remove(listener); if (mMiniControllers.isEmpty()) { stopProgressTimer(); } } } } @Override protected void onDeviceUnselected() { stopNotificationService(); detachMediaChannel(); removeDataChannel(); mState = MediaStatus.PLAYER_STATE_IDLE; mMediaStatus = null; } @Override protected Builder getCastOptionBuilder(CastDevice device) { Builder builder = 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); updateMediaSession(false); mState = MediaStatus.PLAYER_STATE_IDLE; mMediaStatus = null; stopNotificationService(); } @Override public void onDisconnected(boolean stopAppOnExit, boolean clearPersistedConnectionData, boolean setDefaultRoute) { super.onDisconnected(stopAppOnExit, clearPersistedConnectionData, setDefaultRoute); updateMiniControllersVisibility(false); if (clearPersistedConnectionData && !mConnectionSuspended) { clearMediaSession(); } mState = MediaStatus.PLAYER_STATE_IDLE; mMediaStatus = null; mMediaQueue = null; } class CastListener extends Cast.Listener { /* * (non-Javadoc) * @see com.google.android.gms.cast.Cast.Listener#onApplicationDisconnected (int) */ @Override public void onApplicationDisconnected(int statusCode) { VideoCastManager.this.onApplicationDisconnected(statusCode); } /* * (non-Javadoc) * @see com.google.android.gms.cast.Cast.Listener#onApplicationStatusChanged () */ @Override public void onApplicationStatusChanged() { VideoCastManager.this.onApplicationStatusChanged(); } @Override public void onVolumeChanged() { VideoCastManager.this.onVolumeChanged(); } } @Override public void onFailed(int resourceId, int statusCode) { LOGD(TAG, "onFailed: " + mContext.getString(resourceId) + ", code: " + statusCode); super.onFailed(resourceId, statusCode); } /** * Returns the class for the full screen activity that can control the remote media playback. * This activity will also be invoked from the notification shade. If {@code null} is returned, * this library will use a default implementation. * * @see {@link VideoCastControllerActivity} */ public Class<?> getTargetActivity() { return mTargetActivity; } /** * 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: if (changeVolume(volumeDelta, isKeyDown)) { return true; } break; case KeyEvent.KEYCODE_VOLUME_DOWN: if (changeVolume(-volumeDelta, isKeyDown)) { return true; } break; } } 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 { adjustVolume(volumeIncrement); } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(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 VideoCastManager setVolumeStep(double volumeStep) { if ((volumeStep > 1) || (volumeStep < 0)) { throw new IllegalArgumentException("Volume Step should be between 0 and 1, inclusive"); } mVolumeStep = volumeStep; return this; } /** * Returns the volume step. The default value is {@code DEFAULT_VOLUME_STEP}. */ public double getVolumeStep() { return mVolumeStep; } /** * Set the live stream duration; this is purely used in the reconnection logic. If this method * is not called, the default value {@code DEFAULT_LIVE_STREAM_DURATION_MS} is used. * * @param duration Duration, specified in milliseconds. */ public void setLiveStreamDuration(long duration) { mLiveStreamDuration = duration; } /** * Sets the active tracks and their styles. */ public void setActiveTracks(List<MediaTrack> tracks) { long[] tracksArray; if (tracks.isEmpty()) { tracksArray = new long[]{}; } else { tracksArray = new long[tracks.size()]; for (int i = 0; i < tracks.size(); i++) { tracksArray[i] = tracks.get(i).getId(); } } setActiveTrackIds(tracksArray); if (tracks.size() > 0) { setTextTrackStyle(getTracksPreferenceManager().getTextTrackStyle()); } } /** * Sets the active tracks for the currently loaded media. */ public void setActiveTrackIds(long[] trackIds) { if (mRemoteMediaPlayer == null || mRemoteMediaPlayer.getMediaInfo() == null) { return; } mRemoteMediaPlayer.setActiveMediaTracks(mApiClient, trackIds) .setResultCallback(new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult mediaChannelResult) { LOGD(TAG, "Setting track result was successful? " + mediaChannelResult.getStatus().isSuccess()); if (!mediaChannelResult.getStatus().isSuccess()) { LOGD(TAG, "Failed since: " + mediaChannelResult.getStatus() + " and status code:" + mediaChannelResult.getStatus() .getStatusCode()); } } }); } /** * Sets or updates the style of the Text Track. */ public void setTextTrackStyle(TextTrackStyle style) { mRemoteMediaPlayer.setTextTrackStyle(mApiClient, style) .setResultCallback(new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { onFailed(R.string.ccl_failed_to_set_track_style, result.getStatus().getStatusCode()); } } }); for (VideoCastConsumer consumer : mVideoConsumers) { try { consumer.onTextTrackStyleChanged(style); } catch (Exception e) { LOGE(TAG, "onTextTrackStyleChanged(): Failed to inform " + consumer, e); } } } /** * Signals a change in the Text Track style. Clients should not call this directly. */ public void onTextTrackStyleChanged(TextTrackStyle style) { LOGD(TAG, "onTextTrackStyleChanged() reached"); if (mRemoteMediaPlayer == null || mRemoteMediaPlayer.getMediaInfo() == null) { return; } mRemoteMediaPlayer.setTextTrackStyle(mApiClient, style) .setResultCallback(new ResultCallback<MediaChannelResult>() { @Override public void onResult(MediaChannelResult result) { if (!result.getStatus().isSuccess()) { onFailed(R.string.ccl_failed_to_set_track_style, result.getStatus().getStatusCode()); } } }); for (VideoCastConsumer consumer : mVideoConsumers) { try { consumer.onTextTrackStyleChanged(style); } catch (Exception e) { LOGE(TAG, "onTextTrackStyleChanged(): Failed to inform " + consumer, e); } } } /** * Signals a change in the Text Track on/off state. Clients should not call this directly. */ public void onTextTrackEnabledChanged(boolean isEnabled) { LOGD(TAG, "onTextTrackEnabledChanged() reached"); if (!isEnabled) { setActiveTrackIds(new long[]{}); } for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onTextTrackEnabledChanged(isEnabled); } } /** * Signals a change in the Text Track locale. Clients should not call this directly. */ public void onTextTrackLocaleChanged(Locale locale) { LOGD(TAG, "onTextTrackLocaleChanged() reached"); for (VideoCastConsumer consumer : mVideoConsumers) { consumer.onTextTrackLocaleChanged(locale); } } @SuppressLint("NewApi") private void registerCaptionListener(final Context context) { if (Utils.IS_KITKAT_OR_ABOVE) { CaptioningManager captioningManager = (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE); captioningManager.addCaptioningChangeListener( new CaptioningManager.CaptioningChangeListener() { @Override public void onEnabledChanged(boolean enabled) { onTextTrackEnabledChanged(enabled); } @Override public void onUserStyleChanged( @NonNull CaptioningManager.CaptionStyle userStyle) { onTextTrackStyleChanged(mTrackManager.getTextTrackStyle()); } @Override public void onFontScaleChanged(float fontScale) { onTextTrackStyleChanged(mTrackManager.getTextTrackStyle()); } @Override public void onLocaleChanged(Locale locale) { onTextTrackLocaleChanged(locale); } } ); } } /** * Updates the summary of the captions between "on" and "off" based on the user selected * preferences. This can be called by the caller application when they add captions settings to * their preferences. Preferably this should be called in the {@code onResume()} of the * PreferenceActivity so that it gets updated when needed. */ public void updateCaptionSummary(String captionScreenKey, PreferenceScreen preferenceScreen) { int status = R.string.ccl_info_na; if (isFeatureEnabled(CastConfiguration.FEATURE_CAPTIONS_PREFERENCE)) { status = mTrackManager.isCaptionEnabled() ? R.string.ccl_on : R.string.ccl_off; } preferenceScreen.findPreference(captionScreenKey) .setSummary(status); } /** * Returns the instance of {@link TracksPreferenceManager} that is being used. */ public TracksPreferenceManager getTracksPreferenceManager() { return mTrackManager; } /** * Returns the list of current active tracks. If there is no remote media, then this will * return <code>null</code>. */ public long[] getActiveTrackIds() { if (mRemoteMediaPlayer != null && mRemoteMediaPlayer.getMediaStatus() != null) { return mRemoteMediaPlayer.getMediaStatus().getActiveTrackIds(); } return null; } /** * Adds an * {@link com.google.android.libraries.cast.companionlibrary.cast.tracks.OnTracksSelectedListener} // NOLINT * to the lis of listeners. */ public void addTracksSelectedListener(OnTracksSelectedListener listener) { if (listener != null) { mTracksSelectedListeners.add(listener); } } /** * Removes an * {@link com.google.android.libraries.cast.companionlibrary.cast.tracks.OnTracksSelectedListener} // NOLINT * from the lis of listeners. */ public void removeTracksSelectedListener(OnTracksSelectedListener listener) { if (listener != null) { mTracksSelectedListeners.remove(listener); } } /** * Notifies all the * {@link com.google.android.libraries.cast.companionlibrary.cast.tracks.OnTracksSelectedListener} // NOLINT * that the set of active tracks has changed. If there are no listeners registered, then the * cast manager sets that itself. * * @param tracks the set of active tracks. Must be {@code non-null} but can be an empty list. */ public void notifyTracksSelectedListeners(List<MediaTrack> tracks) { if (tracks == null) { throw new IllegalArgumentException("tracks must not be null"); } if (mTracksSelectedListeners.isEmpty()) { setActiveTracks(tracks); } else { for (OnTracksSelectedListener listener : mTracksSelectedListeners) { listener.onTracksSelected(tracks); } } } public final MediaQueue getMediaQueue() { return mMediaQueue; } final private Runnable mProgressRunnable = new Runnable() { @Override public void run() { int currentPos; if (mState == MediaStatus.PLAYER_STATE_BUFFERING || !isConnected() || mRemoteMediaPlayer == null) { return; } try { int duration = (int) getMediaDuration(); if (duration > 0) { currentPos = (int) getCurrentMediaPosition(); updateProgress(currentPos, duration); } } catch (TransientNetworkDisconnectionException | NoConnectionException e) { LOGE(TAG, "Failed to update the progress tracker due to network issues", e); } } }; private void restartProgressTimer() { stopProgressTimer(); mProgressHandler = scheduler .scheduleAtFixedRate(mProgressRunnable, 100, PROGRESS_UPDATE_INTERVAL_MS, TimeUnit.MILLISECONDS); LOGD(TAG, "Restarted Progress Timer"); } private void stopProgressTimer() { LOGD(TAG, "Stopped TrickPlay Timer"); if (mProgressHandler != null && !mProgressHandler.isCancelled()) { mProgressHandler.cancel(true); } } /** * <b>Note:</b> This is called on a worker thread */ private void updateProgress(int currentPosition, int duration) { synchronized (mMiniControllers) { for (final IMiniController controller : mMiniControllers) { controller.setProgress(currentPosition, duration); } } for(ProgressWatcher watcher : mProgressWatchers) { watcher.setProgress(currentPosition, duration); } } /** * Returns the namespace for an additional data namespace that this library can manage for an * application to have an out-of-band communication channel with the receiver. Note that this * only prepares the sender and your own receiver needs to be able to receive and manage the * channel as well. The default implementation is not to set up any additional channel. * * @return The namespace that the library can manage for the application. If {@code null}, no * namespace will be set up. */ protected String getDataNamespace() { return mDataNamespace; } }