/* * Copyright (C) 2014 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.distantfuture.castcompanionlibrary.lib.cast.player; import android.app.Activity; import android.app.AlertDialog; import android.app.Dialog; import android.app.DialogFragment; import android.app.Fragment; import android.content.DialogInterface; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.view.View; import android.widget.SeekBar; import com.distantfuture.castcompanionlibrary.lib.R; import com.distantfuture.castcompanionlibrary.lib.cast.VideoCastManager; import com.distantfuture.castcompanionlibrary.lib.cast.callbacks.VideoCastConsumerImpl; import com.distantfuture.castcompanionlibrary.lib.cast.exceptions.CastException; import com.distantfuture.castcompanionlibrary.lib.cast.exceptions.NoConnectionException; import com.distantfuture.castcompanionlibrary.lib.cast.exceptions.TransientNetworkDisconnectionException; import com.distantfuture.castcompanionlibrary.lib.utils.CastUtils; import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaMetadata; import com.google.android.gms.cast.MediaStatus; import java.net.URL; import java.util.Timer; import java.util.TimerTask; /** * A fragment that provides a mechanism to retain the state and other needed objects for * {@link VideoCastControllerActivity} (or more generally, for any class implementing * {@link IVideoCastController} interface). This can come very handy when setup of that activity * allows for a configuration changes. Most of the logic required for * {@link VideoCastControllerActivity} is maintained in this fragment to enable application * developers provide a different implementation, if desired. * <p/> * This fragment also provides an implementation of {@link IMediaAuthListener} which can be useful * if a pre-authorization is required for playback of a media. */ public class VideoCastControllerFragment extends Fragment implements OnVideoCastControllerListener, IMediaAuthListener { private static final String EXTRAS = "extras"; private static final String TAG = CastUtils.makeLogTag(VideoCastControllerFragment.class); private static boolean sDialogCanceled = false; protected boolean mAuthSuccess = true; private MediaInfo mSelectedMedia; private VideoCastManager mCastManager; private IMediaAuthService mMediaAuthService; private Thread mAuthThread; private Timer mMediaAuthTimer; private Handler mHandler; private IVideoCastController mCastController; private AsyncTask<String, Void, Bitmap> mImageAsyncTask; private Timer mSeekbarTimer; private int mPlaybackState; private MyCastConsumer mCastConsumer; private OverallState mOverallState = OverallState.UNKNOWN; private UrlAndBitmap mUrlAndBitmap; private boolean mIsFresh; /** * Call this static method to create an instance of this fragment. */ public static VideoCastControllerFragment newInstance(Bundle extras) { VideoCastControllerFragment f = new VideoCastControllerFragment(); Bundle b = new Bundle(); b.putBundle(EXTRAS, extras); f.setArguments(b); return f; } @Override public void onAttach(Activity activity) { super.onAttach(activity); mIsFresh = true; sDialogCanceled = false; mCastController = (IVideoCastController) activity; mHandler = new Handler(); try { mCastManager = VideoCastManager.getInstance(activity); } catch (CastException e) { // logged already } } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mCastConsumer = new MyCastConsumer(); Bundle bundle = getArguments(); if (null == bundle) { return; } Bundle extras = bundle.getBundle(EXTRAS); Bundle mediaWrapper = extras.getBundle(VideoCastManager.EXTRA_MEDIA); // Retain this fragment across configuration changes. setRetainInstance(true); if (extras.getBoolean(VideoCastManager.EXTRA_HAS_AUTH)) { mOverallState = OverallState.AUTHORIZING; mMediaAuthService = mCastManager.getMediaAuthService(); handleMediaAuthTask(mMediaAuthService); showImage(CastUtils.getImageUrl(mMediaAuthService.getMediaInfo(), 1)); } else if (null != mediaWrapper) { mOverallState = OverallState.PLAYBACK; boolean shouldStartPlayback = extras.getBoolean(VideoCastManager.EXTRA_SHOULD_START); MediaInfo info = CastUtils.toMediaInfo(mediaWrapper); int startPoint = extras.getInt(VideoCastManager.EXTRA_START_POINT, 0); onReady(info, shouldStartPlayback, startPoint); } } /* * Starts a background thread for starting the Auth Service */ private void handleMediaAuthTask(final IMediaAuthService authService) { mCastController.showLoading(true); mCastController.setLine2(authService.getPendingMessage()); mAuthThread = new Thread(new Runnable() { @Override public void run() { if (null != authService) { try { authService.setOnResult(VideoCastControllerFragment.this); authService.start(); } catch (Exception e) { CastUtils.LOGE(TAG, "mAuthService.start() encountered exception", e); mAuthSuccess = false; } } } }); mAuthThread.start(); // start a timeout timer; we don't want authorization process to take too long mMediaAuthTimer = new Timer(); mMediaAuthTimer.schedule(new MediaAuthServiceTimerTask(mAuthThread), authService.getTimeout()); } public void onReady(MediaInfo mediaInfo, boolean shouldStartPlayback, int startPoint) { mSelectedMedia = mediaInfo; try { mCastController.setStreamType(mSelectedMedia.getStreamType()); if (shouldStartPlayback) { // need to start remote playback mPlaybackState = MediaStatus.PLAYER_STATE_BUFFERING; mCastController.setPlaybackStatus(mPlaybackState); mCastManager.loadMedia(mSelectedMedia, true, startPoint); } else { // we don't change the status of remote playback if (mCastManager.isRemoteMoviePlaying()) { mPlaybackState = MediaStatus.PLAYER_STATE_PLAYING; } else { mPlaybackState = MediaStatus.PLAYER_STATE_PAUSED; } mCastController.setPlaybackStatus(mPlaybackState); } } catch (Exception e) { CastUtils.LOGE(TAG, "Failed to get playback and media information", e); mCastController.closeActivity(); } updateMetadata(); restartTrickplayTimer(); } private void stopTrickplayTimer() { CastUtils.LOGD(TAG, "Stopped TrickPlay Timer"); if (null != mSeekbarTimer) { mSeekbarTimer.cancel(); } } private void restartTrickplayTimer() { stopTrickplayTimer(); mSeekbarTimer = new Timer(); mSeekbarTimer.scheduleAtFixedRate(new UpdateSeekbarTask(), 100, 1000); CastUtils.LOGD(TAG, "Restarted TrickPlay Timer"); } private void updateOverallState() { IMediaAuthService authService; switch (mOverallState) { case AUTHORIZING: authService = mCastManager.getMediaAuthService(); if (null != authService) { mCastController.setLine2(authService.getPendingMessage()); mCastController.showLoading(true); } break; case PLAYBACK: // nothing yet, may be needed in future break; default: break; } } private void updateMetadata() { String imageUrl = null; if (null == mSelectedMedia) { if (null != mMediaAuthService) { imageUrl = CastUtils.getImageUrl(mMediaAuthService.getMediaInfo(), 1); } } else { imageUrl = CastUtils.getImageUrl(mSelectedMedia, 1); } if (null != imageUrl) { showImage(imageUrl); } if (null == mSelectedMedia) { return; } MediaMetadata mm = mSelectedMedia.getMetadata(); mCastController.setLine1(mm.getString(MediaMetadata.KEY_TITLE)); boolean isLive = mSelectedMedia.getStreamType() == MediaInfo.STREAM_TYPE_LIVE; mCastController.adjustControllersForLiveStream(isLive); } private void updatePlayerStatus() { int mediaStatus = mCastManager.getPlaybackStatus(); CastUtils.LOGD(TAG, "onRemoteMediaPlayerStatusUpdated(), status: " + mediaStatus); if (null == mSelectedMedia) { return; } mCastController.setStreamType(mSelectedMedia.getStreamType()); if (mediaStatus == MediaStatus.PLAYER_STATE_BUFFERING) { mCastController.setLine2(getString(R.string.loading)); } else { mCastController.setLine2(getString(R.string.casting_to_device, mCastManager.getDeviceName())); } switch (mediaStatus) { case MediaStatus.PLAYER_STATE_PLAYING: if (mPlaybackState != MediaStatus.PLAYER_STATE_PLAYING) { mPlaybackState = MediaStatus.PLAYER_STATE_PLAYING; mCastController.setPlaybackStatus(mPlaybackState); } break; case MediaStatus.PLAYER_STATE_PAUSED: if (mPlaybackState != MediaStatus.PLAYER_STATE_PAUSED) { mPlaybackState = MediaStatus.PLAYER_STATE_PAUSED; mCastController.setPlaybackStatus(mPlaybackState); } break; case MediaStatus.PLAYER_STATE_BUFFERING: if (mPlaybackState != MediaStatus.PLAYER_STATE_BUFFERING) { mPlaybackState = MediaStatus.PLAYER_STATE_BUFFERING; mCastController.setPlaybackStatus(mPlaybackState); } break; case MediaStatus.PLAYER_STATE_IDLE: switch (mCastManager.getIdleReason()) { case MediaStatus.IDLE_REASON_FINISHED: if (!mIsFresh) { mCastController.closeActivity(); } break; case MediaStatus.IDLE_REASON_CANCELED: try { if (mCastManager.isRemoteStreamLive()) { if (mPlaybackState != MediaStatus.PLAYER_STATE_IDLE) { mPlaybackState = MediaStatus.PLAYER_STATE_IDLE; mCastController.setPlaybackStatus(mPlaybackState); } } } catch (TransientNetworkDisconnectionException e) { CastUtils.LOGD(TAG, "Failed to determine if stream is live", e); } catch (NoConnectionException e) { CastUtils.LOGD(TAG, "Failed to determine if stream is live", e); } default: break; } break; default: break; } mCastController.setPlaybackStatus(mPlaybackState); } @Override public void onDestroy() { CastUtils.LOGD(TAG, "onDestroy()"); stopTrickplayTimer(); cleanup(); super.onDestroy(); } @Override public void onResume() { CastUtils.LOGD(TAG, "onResume() was called"); try { mCastManager = VideoCastManager.getInstance(getActivity()); boolean shouldFinish = !mCastManager.isConnected() || (mCastManager.getPlaybackStatus() == MediaStatus.PLAYER_STATE_IDLE && mCastManager .getIdleReason() == MediaStatus.IDLE_REASON_FINISHED && !mIsFresh); if (shouldFinish) { mCastController.closeActivity(); } mCastManager.addVideoCastConsumer(mCastConsumer); updatePlayerStatus(); mIsFresh = false; mCastManager.incrementUiCounter(); } catch (CastException e) { // logged already } super.onResume(); } @Override public void onPause() { mCastManager.removeVideoCastConsumer(mCastConsumer); mCastManager.decrementUiCounter(); mIsFresh = false; super.onPause(); } /* * Gets the image at the given url and populates the image view with that. It tries to cache the * image to avoid unnecessary network calls. */ private void showImage(final String url) { if (null != mImageAsyncTask) { mImageAsyncTask.cancel(true); } if (null != mUrlAndBitmap && mUrlAndBitmap.isMatch(url)) { // we can reuse mBitmap mCastController.setImage(mUrlAndBitmap.mBitmap); return; } mUrlAndBitmap = null; mImageAsyncTask = new AsyncTask<String, Void, Bitmap>() { @Override protected Bitmap doInBackground(String... params) { String uri = params[0]; try { URL imgUrl = new URL(uri); return BitmapFactory.decodeStream(imgUrl.openStream()); } catch (Exception e) { CastUtils.LOGE(TAG, "Failed to load the image with mUrl: " + uri, e); } return null; } @Override protected void onPostExecute(Bitmap bitmap) { if (null != bitmap) { mUrlAndBitmap = new UrlAndBitmap(); mUrlAndBitmap.mBitmap = bitmap; mUrlAndBitmap.mUrl = url; mCastController.setImage(bitmap); } } }; mImageAsyncTask.execute(url); } /* * Shows an error dialog */ private void showErrorDialog(String message) { ErrorDialogFragment.newInstance(message).show(getFragmentManager(), "dlg"); } // ------- Implementation of OnVideoCastControllerListener interface ----------------- // @Override public void onStopTrackingTouch(SeekBar seekBar) { try { if (mPlaybackState == MediaStatus.PLAYER_STATE_PLAYING) { mPlaybackState = MediaStatus.PLAYER_STATE_BUFFERING; mCastController.setPlaybackStatus(mPlaybackState); mCastManager.play(seekBar.getProgress()); } else if (mPlaybackState == MediaStatus.PLAYER_STATE_PAUSED) { mCastManager.seek(seekBar.getProgress()); } restartTrickplayTimer(); } catch (Exception e) { CastUtils.LOGE(TAG, "Failed to complete seek", e); mCastController.closeActivity(); } } @Override public void onStartTrackingTouch(SeekBar seekBar) { stopTrickplayTimer(); } @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { } @Override public void onPlayPauseClicked(View v) throws CastException, TransientNetworkDisconnectionException, NoConnectionException { CastUtils.LOGD(TAG, "isConnected returning: " + mCastManager.isConnected()); togglePlayback(); } private void togglePlayback() throws CastException, TransientNetworkDisconnectionException, NoConnectionException { switch (mPlaybackState) { case MediaStatus.PLAYER_STATE_PAUSED: mCastManager.play(); mPlaybackState = MediaStatus.PLAYER_STATE_BUFFERING; restartTrickplayTimer(); break; case MediaStatus.PLAYER_STATE_PLAYING: mCastManager.pause(); mPlaybackState = MediaStatus.PLAYER_STATE_BUFFERING; break; case MediaStatus.PLAYER_STATE_IDLE: if ((mSelectedMedia.getStreamType() == MediaInfo.STREAM_TYPE_LIVE) && (mCastManager.getIdleReason() == MediaStatus.IDLE_REASON_CANCELED)) { mCastManager.play(); } else { mCastManager.loadMedia(mSelectedMedia, true, 0); } mPlaybackState = MediaStatus.PLAYER_STATE_BUFFERING; restartTrickplayTimer(); break; default: break; } mCastController.setPlaybackStatus(mPlaybackState); } @Override public void onConfigurationChanged() { updateOverallState(); if (null == mSelectedMedia) { if (null != mMediaAuthService) { showImage(CastUtils.getImageUrl(mMediaAuthService.getMediaInfo(), 1)); } } else { updateMetadata(); updatePlayerStatus(); mCastController.updateControllersStatus(mCastManager.isConnected()); } } // ------- Implementation of IMediaAuthListener interface --------------------------- // @Override public void onResult(MediaAuthStatus status, final MediaInfo info, final String message) { if (status == MediaAuthStatus.RESULT_AUTHORIZED && mAuthSuccess) { // successful authorization mMediaAuthService = null; if (null != mMediaAuthTimer) { mMediaAuthTimer.cancel(); } mSelectedMedia = info; mHandler.post(new Runnable() { @Override public void run() { mOverallState = OverallState.PLAYBACK; onReady(info, true, 0); } }); } else { if (null != mMediaAuthTimer) { mMediaAuthTimer.cancel(); } mHandler.post(new Runnable() { @Override public void run() { mOverallState = OverallState.UNKNOWN; showErrorDialog(message); } }); } } @Override public void onFailure(final String failureMessage) { if (null != mMediaAuthTimer) { mMediaAuthTimer.cancel(); } mHandler.post(new Runnable() { @Override public void run() { mOverallState = OverallState.UNKNOWN; showErrorDialog(failureMessage); } }); } /* * Cleanup of threads and timers and bitmap and ... */ private void cleanup() { IMediaAuthService authService = mCastManager.getMediaAuthService(); if (null != mMediaAuthTimer) { mMediaAuthTimer.cancel(); } if (null != mAuthThread) { mAuthThread = null; } if (null != mCastManager.getMediaAuthService()) { authService.setOnResult(null); mCastManager.removeMediaAuthService(); } if (null != mCastManager) { mCastManager.removeVideoCastConsumer(mCastConsumer); } if (null != mHandler) { mHandler.removeCallbacksAndMessages(null); } if (null != mUrlAndBitmap) { mUrlAndBitmap.mBitmap = null; } if (!sDialogCanceled && null != mMediaAuthService) { mMediaAuthService.abort(MediaAuthStatus.ABORT_USER_CANCELLED); } } private enum OverallState { AUTHORIZING, PLAYBACK, UNKNOWN } /* * A modal dialog with an OK button, where upon clicking on it, will finish the activity. We use * a DilaogFragment so during configuration changes, system manages the dialog for us. */ public static class ErrorDialogFragment extends DialogFragment { private static final String MESSAGE = "message"; private IVideoCastController mController; public static ErrorDialogFragment newInstance(String message) { ErrorDialogFragment frag = new ErrorDialogFragment(); Bundle args = new Bundle(); args.putString(MESSAGE, message); frag.setArguments(args); return frag; } @Override public void onAttach(Activity activity) { mController = (IVideoCastController) activity; super.onAttach(activity); setCancelable(false); } @Override public Dialog onCreateDialog(Bundle savedInstanceState) { String message = getArguments().getString(MESSAGE); return new AlertDialog.Builder(getActivity()).setTitle(R.string.error) .setMessage(message) .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { sDialogCanceled = true; mController.closeActivity(); } }) .create(); } } /* * A TimerTask that will be called when the timeout timer expires */ class MediaAuthServiceTimerTask extends TimerTask { private final Thread mThread; public MediaAuthServiceTimerTask(Thread thread) { this.mThread = thread; } @Override public void run() { if (null != mThread) { CastUtils.LOGD(TAG, "Timer is expired, going to interrupt the thread"); mThread.interrupt(); mHandler.post(new Runnable() { @Override public void run() { mCastController.showLoading(false); showErrorDialog(getString(R.string.failed_authorization_timeout)); mAuthSuccess = false; if (null != mMediaAuthService && mMediaAuthService.getStatus() == MediaAuthStatus.PENDING) { mMediaAuthService.abort(MediaAuthStatus.ABORT_TIMEOUT); } } }); } } } private class MyCastConsumer extends VideoCastConsumerImpl { @Override public void onDisconnected() { mCastController.closeActivity(); } @Override public void onApplicationDisconnected(int errorCode) { mCastController.closeActivity(); } @Override public void onRemoteMediaPlayerMetadataUpdated() { try { mSelectedMedia = mCastManager.getRemoteMediaInformation(); updateMetadata(); } catch (TransientNetworkDisconnectionException e) { CastUtils.LOGE(TAG, "Failed to update the metadata due to network issues", e); } catch (NoConnectionException e) { CastUtils.LOGE(TAG, "Failed to update the metadata due to network issues", e); } } @Override public void onRemoteMediaPlayerStatusUpdated() { updatePlayerStatus(); } @Override public void onConnectionSuspended(int cause) { mCastController.updateControllersStatus(false); } @Override public void onConnectivityRecovered() { mCastController.updateControllersStatus(true); } } // ----------- Some utility methods --------------------------------------------------------- // private class UpdateSeekbarTask extends TimerTask { @Override public void run() { mHandler.post(new Runnable() { @Override public void run() { int currentPos; if (mPlaybackState == MediaStatus.PLAYER_STATE_BUFFERING) { return; } if (!mCastManager.isConnected()) { return; } try { double duration = mCastManager.getMediaDuration(); if (duration > 0) { try { currentPos = (int) mCastManager.getCurrentMediaPosition(); mCastController.updateSeekbar(currentPos, (int) duration); } catch (Exception e) { CastUtils.LOGE(TAG, "Failed to get current media position"); } } } catch (TransientNetworkDisconnectionException e) { CastUtils.LOGE(TAG, "Failed to update the progress bar due to network issues", e); } catch (NoConnectionException e) { CastUtils.LOGE(TAG, "Failed to update the progress bar due to network issues", e); } } }); } } /* * A simple class that holds a URL and a bitmap, mainly used to cache the fetched image */ private class UrlAndBitmap { private Bitmap mBitmap; private String mUrl; private boolean isMatch(String url) { return null != url && null != mBitmap && url.equals(mUrl); } } }