Saturday, November 8, 2014

Building an Android Google Cast Sender App

This post was originally written for BinPress.

    With millions of Chromecasts sold and the Android TV ready to burst into living rooms, knowing how to build an app that supports casting should be in every Android developer's toolkit. This post will cover making an Android sender app to cast videos to a receiver app for displaying content on a television. All code for this example can be found on GitHub.

    For this tutorial, we'll use an unstyled receiver application created through the Google Cast Developer Console. While there is a generic default receiver application that can be used, this will provide an option to style the default receiver, or build your own if needed. Once the generic receiver application has been created, make note of the application ID, as we'll use that later. If you haven't already, you'll also want to register your casting device in order to run debug applications through Google Cast.

    Now, let's get to work on our Android application. The first thing that should be done is adding the libraries that we'll need to our build.gradle file.

compile 'com.android.support:appcompat-v7:20.0.0'
compile 'com.android.support:mediarouter-v7:19.0.+'
compile 'com.google.android.gms:play-services:6.1.11'

    Once build.gradle is all set, we can add the Internet permission and play services to AndroidManifest.xml

<uses-permission android:name="android.permission.INTERNET" />

<application
    android:allowBackup="true"
    android:icon="@drawable/ic_launcher"
    android:label="@string/app_name"
    android:theme="@style/Theme.AppCompat" >

    <meta-data
        android:name="com.google.android.gms.version"
        android:value="@integer/google_play_services_version" />

 ...

    The next thing that we'll need to do is add the media routing button to our activity's menu file. This action bar button handles all of the state logic needed for hiding or displaying the casting button, as well as coloring it when the app has connected to a casting device.

<item android:id="@+id/media_route_menu_item"
    android:title="Chromecast"
    app:actionProviderClass="android.support.v7.app.MediaRouteActionProvider"
    app:showAsAction="always"
    />

    For our last housekeeping item, we'll need to add some values to strings.xml, such as the video URL, receiver application ID and general UI strings. You'll need to replace app_id with your application ID, and video_url with a video that you would want to play. For this example I picked up a link from Archive.org for the George Romero classic Night of the Living Dead.

<string name="app_name">Chromecast</string>
<string name="video_url">xxxxx</string>
<string name="app_id">xxxxx</string>
<string name="play_video">Play Video</string>
<string name="pause_video">Pause Video</string>
<string name="resume_video">Resume Video</string>
<string name="video_title">Paul\'s Chromecast Video Stream</string>
<string name="content_type_mp4">video/mp4</string> 

    Now that all of the preparation work is done, we can move on to working with our casting activity - MainActivity.java. To start, I've added a set of declarations to the top of the class to keep track of our casting components and general state logic.

private Button mButton;

private MediaRouter mMediaRouter;
private MediaRouteSelector mMediaRouteSelector;
private MediaRouter.Callback mMediaRouterCallback;
private CastDevice mSelectedDevice;
private GoogleApiClient mApiClient;
private RemoteMediaPlayer mRemoteMediaPlayer;
private Cast.Listener mCastClientListener;
private boolean mWaitingForReconnect = false;
private boolean mApplicationStarted = false;
private boolean mVideoIsLoaded;
private boolean mIsPlaying;

    The UI for this activity consists of a single button that will start a video if the app is connected to a casting device, or can pause/resume a video once it has been started, and the media router button in the action bar. In onCreate we simply initialize the click listener for the main control button and the media router, selector and callbacks.

@Override
protected void onCreate( Bundle savedInstanceState ) {
    super.onCreate( savedInstanceState );
    setContentView( R.layout.activity_main );

    mButton = (Button) findViewById( R.id.button );
    mButton.setOnClickListener( new OnClickListener() {
        @Override
        public void onClick( View v ) {
           if( !mVideoIsLoaded )
               startVideo();
           else
               controlVideo();
        }
    });

    initMediaRouter();
}

private void initMediaRouter() {
    // Configure Cast device discovery
    mMediaRouter = MediaRouter.getInstance( getApplicationContext() );
    mMediaRouteSelector = new MediaRouteSelector.Builder()
            .addControlCategory( 
                CastMediaControlIntent.categoryForCast( getString( R.string.app_id ) ) )
            .build();
    mMediaRouterCallback = new MediaRouterCallback();
}

    Since the media router button exists in a menu file, we need to also override onCreateOptionsMenu and associate our selector with the MediaRouteActionProvider for the media router menu item.

@Override
public boolean onCreateOptionsMenu( Menu menu ) {
    super.onCreateOptionsMenu( menu );
    getMenuInflater().inflate( R.menu.main, menu );
    MenuItem mediaRouteMenuItem = menu.findItem( R.id.media_route_menu_item );
    MediaRouteActionProvider mediaRouteActionProvider = (MediaRouteActionProvider) MenuItemCompat.getActionProvider( mediaRouteMenuItem );
    mediaRouteActionProvider.setRouteSelector( mMediaRouteSelector );
    return true;
}

    We'll also want to associate the media router callbacks with our router button in onResume.

@Override
protected void onResume() {
    super.onResume();
    // Start media router discovery
    mMediaRouter.addCallback( mMediaRouteSelector, mMediaRouterCallback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN );
}

    mMediaRouterCallback is an extension of MediaRouter.Callback, and provides methods for performing actions when a route is selected or unselected.

private class MediaRouterCallback extends MediaRouter.Callback {

    @Override
    public void onRouteSelected(MediaRouter router, RouteInfo info) {
        initCastClientListener();
        initRemoteMediaPlayer();

        mSelectedDevice = CastDevice.getFromBundle( info.getExtras() );

        launchReceiver();
    }

    @Override
    public void onRouteUnselected( MediaRouter router, RouteInfo info ) {
        teardown();
        mSelectedDevice = null;
        mButton.setText( getString( R.string.play_video ) );
        mVideoIsLoaded = false;
    }
}

    At this point your application should display the media router button in the action bar if you are on a network with a casting device (assuming you have stub methods in where necessary).


    It will also allow you to choose a casting device to interact with.


    As you probably noticed, MediaRouterCallback does a few different things in onRouteSelected. First it initializes mCastClientListener, which has methods for checking when an application status has changed, volume has changed or the client has disconnected from the receiver application (I'll go over the teardown method later).

private void initCastClientListener() {
    mCastClientListener = new Cast.Listener() {
        @Override
        public void onApplicationStatusChanged() {
        }

        @Override
        public void onVolumeChanged() {
        }

        @Override
        public void onApplicationDisconnected( int statusCode ) {
            teardown();
        }
    };
}

    Next the RemoteMediaPlayer is initialized. This object controls playback with the receiver, as well as provides a lot of useful state and status information.

    private void initRemoteMediaPlayer() {
        mRemoteMediaPlayer = new RemoteMediaPlayer();
        mRemoteMediaPlayer.setOnStatusUpdatedListener( new RemoteMediaPlayer.OnStatusUpdatedListener() {
            @Override
            public void onStatusUpdated() {
                MediaStatus mediaStatus = mRemoteMediaPlayer.getMediaStatus();
                mIsPlaying = mediaStatus.getPlayerState() == MediaStatus.PLAYER_STATE_PLAYING;
            }
        });

        mRemoteMediaPlayer.setOnMetadataUpdatedListener( new RemoteMediaPlayer.OnMetadataUpdatedListener() {
            @Override
            public void onMetadataUpdated() {
            }
        });
    }

    Next, the selected device is stored and the receiver application is launched by using Google Play Services

private void launchReceiver() {
    Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions
            .builder( mSelectedDevice, mCastClientListener );

    ConnectionCallbacks mConnectionCallbacks = new ConnectionCallbacks();
    ConnectionFailedListener mConnectionFailedListener = new ConnectionFailedListener();
    mApiClient = new GoogleApiClient.Builder( this )
            .addApi( Cast.API, apiOptionsBuilder.build() )
            .addConnectionCallbacks( mConnectionCallbacks )
            .addOnConnectionFailedListener( mConnectionFailedListener )
            .build();

    mApiClient.connect();
}

    ConnectionCallbacks is another inner class that extends GoogleApiClient.ConnectionCallbacks. When the API client has connected from launchReceiver, the Cast API is used to actually launch the receiver specified by the application ID that we received earlier from the Google Cast Developers Console

private class ConnectionCallbacks implements GoogleApiClient.ConnectionCallbacks {

@Override
public void onConnected( Bundle hint ) {
    if( mWaitingForReconnect ) {
        mWaitingForReconnect = false;
        reconnectChannels( hint );
    } else {
        try {
            Cast.CastApi.launchApplication( mApiClient, getString( R.string.app_id ), false )
                    .setResultCallback( 
                         new ResultCallback<Cast.ApplicationConnectionResult>() {
                        @Override
                        public void onResult(
                           Cast.ApplicationConnectionResult applicationConnectionResult) {
                            Status status = applicationConnectionResult.getStatus();
                            if( status.isSuccess() ) {
                                //Values that can be useful for storing/logic
                                ApplicationMetadata applicationMetadata = 
                                       applicationConnectionResult.getApplicationMetadata();
                                String sessionId = 
                                       applicationConnectionResult.getSessionId();
                                String applicationStatus = 
                                       applicationConnectionResult.getApplicationStatus();
                                boolean wasLaunched = 
                                       applicationConnectionResult.getWasLaunched();

                                mApplicationStarted = true;
                                reconnectChannels( null );
                            }
                        }
                    }
            );
        } catch ( Exception e ) {

        }
    }
}

@Override
public void onConnectionSuspended(int i) {
    mWaitingForReconnect = true;
}

    At this point the application will start up our generic receiver application on the television.


    In onCreate we added a method call to startVideo. As you may have guessed, this method is used to start video playback on the receiver if it is connected. This method is also where we could start a local player if the app is not connected to a receiver.

private void startVideo() {
    MediaMetadata mediaMetadata = new MediaMetadata( MediaMetadata.MEDIA_TYPE_MOVIE );
    mediaMetadata.putString( MediaMetadata.KEY_TITLE, getString( R.string.video_title ) );

    MediaInfo mediaInfo = new MediaInfo.Builder( getString( R.string.video_url ) )
            .setContentType( getString( R.string.content_type_mp4 ) )
            .setStreamType( MediaInfo.STREAM_TYPE_BUFFERED )
            .setMetadata( mediaMetadata )
            .build();
    try {
        mRemoteMediaPlayer.load( mApiClient, mediaInfo, true )
                .setResultCallback( new ResultCallback<RemoteMediaPlayer.MediaChannelResult>() {
                    @Override
                    public void onResult( RemoteMediaPlayer.MediaChannelResult mediaChannelResult ) {
                        if( mediaChannelResult.getStatus().isSuccess() ) {
                            mVideoIsLoaded = true;
                            mButton.setText( getString( R.string.pause_video ) );
                        }
                    }
                } );
    } catch( Exception e ) {
    }
} 




    When the video is casting to the receiver, the main UI button switches functionality to simply pausing or resuming the video by sending messages over the RemoteMediaControl

private void controlVideo() {
    if( mRemoteMediaPlayer == null || !mVideoIsLoaded )
        return;

    if( mIsPlaying ) {
        mRemoteMediaPlayer.pause( mApiClient );
        mButton.setText( getString( R.string.resume_video ) );
    } else {
        mRemoteMediaPlayer.play( mApiClient );
        mButton.setText( getString( R.string.pause_video ) );
    }
}



    The last few methods to cover generally have to do with some cleanup to keep the app from crashing and to properly close a connection to the receiver. When the connection fails or the app is no longer running, we will call the teardown method.

private void reconnectChannels( Bundle hint ) {
    if( ( hint != null ) && hint.getBoolean( Cast.EXTRA_APP_NO_LONGER_RUNNING ) ) {
        //Log.e( TAG, "App is no longer running" );
        teardown();
    } else {
        try {
            Cast.CastApi.setMessageReceivedCallbacks( mApiClient, mRemoteMediaPlayer.getNamespace(), mRemoteMediaPlayer );
        } catch( IOException e ) {
            //Log.e( TAG, "Exception while creating media channel ", e );
        } catch( NullPointerException e ) {
            //Log.e( TAG, "Something wasn't reinitialized for reconnectChannels" );
        }
    }
}

private class ConnectionFailedListener implements GoogleApiClient.OnConnectionFailedListener {
    @Override
    public void onConnectionFailed( ConnectionResult connectionResult ) {
        teardown();
    }
}

    ...where teardown simply closes the casting connection and disconnects from Play Services.

private void teardown() {
    if( mApiClient != null ) {
        if( mApplicationStarted ) {
            try {
                Cast.CastApi.stopApplication( mApiClient );
                if( mRemoteMediaPlayer != null ) {
                    Cast.CastApi.removeMessageReceivedCallbacks( mApiClient, mRemoteMediaPlayer.getNamespace() );
                    mRemoteMediaPlayer = null;
                }
            } catch( IOException e ) {
                //Log.e( TAG, "Exception while removing application " + e );
            }
            mApplicationStarted = false;
        }
        if( mApiClient.isConnected() )
            mApiClient.disconnect();
        mApiClient = null;
    }
    mSelectedDevice = null;
    mVideoIsLoaded = false;
}


    We also have to remember to remove callbacks from the MediaRouter that we added during onResume.

@Override
protected void onPause() {
    if ( isFinishing() ) {
        // End media router discovery
        mMediaRouter.removeCallback( mMediaRouterCallback );
    }
    super.onPause();
}

    And with that, we have the base functionality for a Google Cast capable Android sender app. If the source code on GitHub doesn't make sense, or you have other questions, please comment below!