Saturday, July 19, 2014

Creating a Media App for AndroidTV

    One of the newest technologies released by Google this year at I/O is the AndroidTV, a platform that takes what they learned from the Chromecast and Google TV and makes it even better by building on the Android OS. For this tutorial I will go over putting together a basic media app for the AndroidTV in order to help other developers get their own apps and content out for this platform in order to push for its success. While the base Android project from Android Studio does let you create a media app, it provides far more than the basics and needs a lot of clean up, so I went ahead and did that clean up and will go over the different methods used in order to hopefully provide some clarity. While I happened to have received my AndroidTV ADT-1 developer box at I/O by luckily sitting in on the session, other developers can request a kit through Google's site. The source code for this project can be found on GitHub.

    The first thing we need to do in a new Android project is set up our gradle file. AndroidTV is currently using the Android-L SDK and has dependencies on the new support library for leanback, recyclerview, and appcompat, then third party libraries used are GSON and Picasso

android {
    compileSdkVersion 'android-L'
    buildToolsVersion "20.0.0"
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:recyclerview-v7:+'
    compile 'com.android.support:leanback-v17:+'
    compile 'com.android.support:appcompat-v7:+'
    compile 'com.squareup.picasso:picasso:2.3.2'
    compile 'com.google.code.gson:gson:2.2.4'
}

    Next we need to set up our manifest to use the Internet permission and know what activities we will use in the project. The main two activities for this project are the MainActivity and PlayerActivity, though I also include Bonus.SlothActivity, which I added to this project to play around with creating a custom activity and decided to leave in the app to show that you can use any activity on this platform (I'll go over this at the end, since I had fun with it). Note that the category the the MainActivity intent-filter is leanback_launcher, as this is what allows the app icon to show up on the television.

<uses-permission android:name="android.permission.INTERNET" />
<uses-feature
    android:name="android.hardware.touchscreen"
    android:required="false" />

<application
    android:allowBackup="true"
    android:icon="@drawable/ic_launcher"
    android:label="@string/app_name"
    android:theme="@style/AppTheme" >
    <activity
        android:name=".Activity.MainActivity"
        android:label="@string/app_name"
        android:logo="@drawable/giraffes"
        android:screenOrientation="landscape" >
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />

            <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
        </intent-filter>
    </activity>
    <activity
        android:name=".Activity.PlayerActivity"
        android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
    <activity android:name=".Activity.DetailsActivity" />

    <activity
        android:name=".Bonus.SlothActivity"
        android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />

</application>

    Under our styles file, we need to set AppTheme to extends the Leanback theme, otherwise the app will not load, however it's still not certain if this will be the case when Android L and AndroidTV officially launch.

<style name="AppTheme" parent="@style/Theme.Leanback">

    Now that the general configuration is set, we should be able to start moving into the actual java code. MainActivity is simply a base activity with a layout that contains a fragment, so we'll put together the layout for now and then jump into how MainFragment works.

public class MainActivity extends Activity {

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


<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main_browse_fragment"
    android:name="com.ptrprograms.androidtvmediaplayer.Fragment.MainFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    tools:deviceIds="tv"
    tools:ignore="MergeRootFrame" />


    MainFragment.java extends the new BrowseFragment provided in the support library, which gives us a lot of the functionality we need for a media app such as the fast lane (side navigation bar) and view for the rows of content. The only member variable that we'll need here is a List of Movie objects, where Movie is just a standard serialized model object containing information about a movie like the title, category, description and URLs to associated images and the video.

public class Movie implements Serializable {
    private String title;
    private String description;
    private String studio;
    private String videoUrl;
    private String category;
    private String cardImageUrl;
    private String backgroundImageUrl;
...

    The first thing that MainFragment does in onActivityCreated is load the data for the list of Movie objects. In this case I'm simply using a locally stored JSON file and converting it into the list using GSON, but this method can be used however works for your project to load in data, be it locally or from an online API.

private void loadData() {
    String json = Utils.loadJSONFromResource( getActivity(), R.raw.movies );
    Gson gson = new Gson();
    Type collection = new TypeToken<ArrayList<Movie>>(){}.getType();
    mMovies = gson.fromJson( json, collection );
}

   Next we can go ahead and initialize the base UI by setting the title in the top right of the screen, enabling category headers, setting the action the back button on the remote should take (opening the fast lane or returning to the home screen), setting fragment colors and background. I should mention that I only set the background once in MainFragment, however one set of polish that you can add to your app is changing the background whenever a new item is selected in order to match the selected item. Also, there is a method built into BrowseFragment called setBadgeDrawable which lets you pass a drawable resource id to display an image in the top right corner rather than plain text.

private void initUI() {
    setTitle( getString( R.string.browse_title ) );
    setHeadersState( HEADERS_ENABLED );

    //Back button goes to the fast lane, rather than home screen
    setHeadersTransitionOnBackEnabled( true );

    setBrandColor( getResources().getColor( R.color.fastlane_background ) );
    setSearchAffordanceColor( getResources().getColor( R.color.search_button_color ) );
    setBackground();
}

Example of the fast lane with background color, search circle with background color and title text. I stuck with a black/grey theme, which may not be the best for examples, but was far easier on my eyes without having to be great at design :)
    After our colors and base UI are set, we can start populated data in order to get the categories in the fast lane and cards that you see in the image above. We do this by creating a new adapter of ListRowPresenters to hold the rows, then create sets of ListRows containing our cards to put into that adapter (think linked list of linked lists) before assigning the adapter of ListRows to the fragment, similar to using setAdapter in a ListFragment. For this method each row will consist of cards representing Movie objects from the same category - comedy, horror, or action.

private void loadRows() {

    ArrayObjectAdapter rowsAdapter = new ArrayObjectAdapter( new ListRowPresenter() );
    CardPresenter cardPresenter = new CardPresenter();

    List<String> categories = getCategories();
    if( categories == null || categories.isEmpty() )
        return;

    for( String category : categories ) {
        ArrayObjectAdapter listRowAdapter = new ArrayObjectAdapter( cardPresenter );
        for( Movie movie : mMovies ) {
            if( category.equalsIgnoreCase( movie.getCategory() ) )
                listRowAdapter.add( movie );
        }
        if( listRowAdapter.size() > 0 ) {
            HeaderItem header = new HeaderItem( rowsAdapter.size() - 1, category, null );
            rowsAdapter.add( new ListRow( header, listRowAdapter ) );
        }
    }

    setupPreferences( rowsAdapter );
    setAdapter( rowsAdapter );

}

    As you can see in the second loop, for each movie we check to see if the movie category matches the current row category, and if it does we add it to our adapter of CardPresenter objects. The CardPresenter extends the Presenter class to bind our movie object to a programmatically created view and also implements the ViewHolder pattern. The main methods for the card presenter are shown here and are from CardPresenter.java

static class ViewHolder extends Presenter.ViewHolder {
    private ImageCardView mCardView;
    private PicassoImageCardViewTarget mImageCardViewTarget;

    public ViewHolder( View view ) {
        super( view );
        mCardView = (ImageCardView) view;
        mImageCardViewTarget = new PicassoImageCardViewTarget( mCardView );
    }

    public ImageCardView getCardView() {
        return mCardView;
    }

    protected void updateCardViewImage( Context context, String link ) {
        Picasso.with( context )
                .load(link)
                .resize( mCardView.getResources().getInteger( R.integer.card_presenter_width ), mCardView.getResources().getInteger( R.integer.card_presenter_height ) )
                .centerCrop()
                .error( R.drawable.default_background )
                .into( mImageCardViewTarget );
    }
}

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent) {
    ImageCardView cardView = new ImageCardView( parent.getContext() );
    cardView.setFocusable( true );
    cardView.setFocusableInTouchMode( true );
    return new ViewHolder(cardView);
}

@Override
public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) {
    Movie movie = (Movie) item;

    if ( !TextUtils.isEmpty( movie.getCardImageUrl() ) ) {
        ((ViewHolder) viewHolder).mCardView.setTitleText( movie.getTitle() );
        ((ViewHolder) viewHolder).mCardView.setContentText( movie.getStudio() );
        ((ViewHolder) viewHolder).mCardView.setMainImageDimensions(
                ( (ViewHolder) viewHolder ).mCardView.getContext().getResources().getInteger( R.integer.card_presenter_width ),
                ( (ViewHolder) viewHolder ).mCardView.getContext().getResources().getInteger( R.integer.card_presenter_height ) );
        ( (ViewHolder) viewHolder ).updateCardViewImage( ( (ViewHolder) viewHolder ).getCardView().getContext(), movie.getCardImageUrl() );
    }
}

Switching between rows using the fast lane
Example of the cards full view in a row with their category header
    Returning to our loadRows method in MainFragment.java, we have a method called setupPreferences. All this does is low another row at the end of our rows adapter that contains a PreferenceCardPresenter (similar to the CardPresenter class above, but greatly simplified to just display text in a square). I'm only adding one preference here as an example.

private void setupPreferences( ArrayObjectAdapter adapter ) {

    HeaderItem gridHeader = new HeaderItem( adapter.size(), "Preferences", null );
    PreferenceCardPresenter mGridPresenter = new PreferenceCardPresenter();
    ArrayObjectAdapter gridRowAdapter = new ArrayObjectAdapter( mGridPresenter );
    gridRowAdapter.add( getResources().getString( R.string.sloth ) );
    adapter.add( new ListRow( gridHeader, gridRowAdapter ) );

}


    The final thing that we need to do once MainActivity/MainFragment are created is initialize our event listeners. The search button in BrowseFragment has a special method for this called setOnSearchClickedListener, however I will leave implementing search for another post. The other event listener that needs to be set is the BrowseFragment's setOnItemClickedListener like so

private void setupEventListeners() {
    setOnItemClickedListener( getDefaultItemClickedListener() );
    setOnSearchClickedListener( new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            Toast.makeText(getActivity(), "Implement your own in-app search", Toast.LENGTH_LONG).show();
        }
    });
}

    where the default item clicked listener checks to see if the data item is a Movie object, and if it is then it packages it into an intent to launch the DetailsActivity which simply houses VideoDetailsFragment. If it is a string, then we know it belongs to our Preference row (though we could just as easily have created a special Preference object to populate that row) and we launch the SlothActivity that I will show later.

protected OnItemClickedListener getDefaultItemClickedListener() {
    return new OnItemClickedListener() {
        @Override
        public void onItemClicked( Object item, Row row ) {
            if( item instanceof Movie ) {
                Movie movie = (Movie) item;
                Intent intent = new Intent( getActivity(), DetailsActivity.class );
                intent.putExtra( VideoDetailsFragment.EXTRA_MOVIE, movie );
                startActivity( intent );
            } else if( item instanceof String ) {
                if( ((String) item).equalsIgnoreCase( getString( R.string.sloth ) ) ) {
                    Intent intent = new Intent( getActivity(), SlothActivity.class );
                    startActivity( intent );
                }
            }
        }
    };
}

    The VideoDetailsFragment extends the new DetailsFragment class and provides a quick overview of a media object with options for the user to select. The first thing we do in this fragment is retrieve the Movie object from the intent that started the DetailsActivity and set up the background for the activity using Picasso from the Movie's background URL property

private void initBackground() {
    BackgroundManager backgroundManager = BackgroundManager.getInstance(getActivity());
    backgroundManager.attach(getActivity().getWindow());
    mBackgroundTarget = new PicassoBackgroundManagerTarget( backgroundManager );

    mMetrics = new DisplayMetrics();
    getActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);

    if( mSelectedMovie != null && !TextUtils.isEmpty( mSelectedMovie.getBackgroundImageUrl() ) ) {
        try {
            updateBackground(new URI(mSelectedMovie.getBackgroundImageUrl()));
        } catch (URISyntaxException e) { }
    }
}

protected void updateBackground(URI uri) {
    if( uri.toString() == null ) {
        try {
            uri = new URI("");
        } catch( URISyntaxException e ) {}
    }

    Picasso.with(getActivity())
            .load( uri.toString() )
            .error( getResources().getDrawable( R.drawable.default_background ) )
            .resize( mMetrics.widthPixels, mMetrics.heightPixels )
            .into( mBackgroundTarget );
}

    Next we use an AsyncTask to create the DetailsOverviewRow and assign the selected Movie object to it, populate information and set up our actions (the buttons at the bottom of the details card) to do their jobs. In this case I only have one action, and it sends an intent to the PlayerActivity in order to play the selected item.

@Override
protected DetailsOverviewRow doInBackground( Movie... movies ) {
    mSelectedMovie = movies[0];
    DetailsOverviewRow row = null;
    try {
        row = new DetailsOverviewRow( mSelectedMovie );
        Bitmap poster = Picasso.with( getActivity() )
                .load( mSelectedMovie.getCardImageUrl() )
                .resize(Utils.dpToPx( getActivity().getResources().getInteger( R.integer.detail_thumbnail_square_size ), getActivity().getApplicationContext() ),
                        Utils.dpToPx( getActivity().getResources().getInteger( R.integer.detail_thumbnail_square_size ), getActivity().getApplicationContext() ) )
                .centerCrop()
                .get();
        row.setImageBitmap( getActivity(), poster );
    } catch ( IOException e ) {
        getActivity().finish();
        return null;
    } catch( NullPointerException e ) {
        getActivity().finish();
        return null;
    }

    row.addAction( new Action( ACTION_WATCH, getResources().getString(
            R.string.watch ), getResources().getString( R.string.watch_subtext) ) );

    return row;
}

@Override
protected void onPostExecute(DetailsOverviewRow detailRow) {
    if( detailRow == null )
        return;

    ClassPresenterSelector ps = new ClassPresenterSelector();
    DetailsOverviewRowPresenter dorPresenter =
            new DetailsOverviewRowPresenter( new DetailsDescriptionPresenter() );
    // set detail background and style
    dorPresenter.setBackgroundColor( getResources().getColor( R.color.detail_background ) );
    dorPresenter.setStyleLarge( true );
    dorPresenter.setOnActionClickedListener( new OnActionClickedListener() {
        @Override
        public void onActionClicked( Action action ) {
            if (action.getId() == ACTION_WATCH ) {
                Intent intent = new Intent( getActivity(), PlayerActivity.class );
                intent.putExtra( EXTRA_MOVIE, mSelectedMovie );
                intent.putExtra( EXTRA_SHOULD_AUTO_START, true );
                startActivity( intent );
            }
        }
    });

    ps.addClassPresenter( DetailsOverviewRow.class, dorPresenter );
    ps.addClassPresenter( ListRow.class,
            new ListRowPresenter() );


    ArrayObjectAdapter adapter = new ArrayObjectAdapter( ps );
    adapter.add( detailRow );
    loadRelatedMedia( adapter );
    setAdapter( adapter );
}



    Notice the loadRelatedMedia method in onPostExecute. This method is used to (you guessed it) load related media onto the details screen. The only thing I'm doing in this method is extracting all of the movies of the same category as the selected movie from the Movies List and displaying them using the CardPresenter presenter from MainFragment, though more appropriate related movies can be gathered or media apps that have that logic in place.

private void loadRelatedMedia( ArrayObjectAdapter adapter ) {

    String json = Utils.loadJSONFromResource( getActivity(), R.raw.movies );
    Gson gson = new Gson();
    Type collection = new TypeToken<ArrayList<Movie>>(){}.getType();
    List<Movie> movies = gson.fromJson( json, collection );
    List<Movie> related = new ArrayList<Movie>();
    for( Movie movie : movies ) {
        if( movie.getCategory().equals( mSelectedMovie.getCategory() ) ) {
            related.add( movie );
        }
    }

    if( related.isEmpty() )
        return;

    ArrayObjectAdapter listRowAdapter = new ArrayObjectAdapter( new CardPresenter() );
    for( Movie movie : related ) {
        listRowAdapter.add( movie );
    }

    HeaderItem header = new HeaderItem( 0, "Related", null );
    adapter.add( new ListRow( header, listRowAdapter ) );
}



    The final important part to a base media project is the actual video player. In this case all of that logic is handled by PlayerActivity.java (which I took from the Android sample app, rather than writing my own, though I do have a post on making a native video player from a few months ago that isn't as great as the Android sample player) and there isn't anything too out of the norm for a video player in this class. The most important part of this video player is the addition of a onKeyDown method that listens for input from the remote control dpad for controlling playback:

@Override
public boolean onKeyDown( int keyCode, KeyEvent event ) {
    int currentPos = 0;
    int delta = mDuration / getResources().getInteger( R.integer.scrub_segment_divisor );
    if ( delta < getResources().getInteger( R.integer.min_scrub_time ) )
        delta = getResources().getInteger( R.integer.min_scrub_time );

    if ( mControllers.getVisibility() != View.VISIBLE ) {
        mControllers.setVisibility( View.VISIBLE );
    }
    switch ( keyCode ) {
        case KeyEvent.KEYCODE_DPAD_CENTER:
            return true;
        case KeyEvent.KEYCODE_DPAD_DOWN:
            return true;
        case KeyEvent.KEYCODE_DPAD_LEFT:
            currentPos = mVideoView.getCurrentPosition();
            currentPos -= delta;
            if (currentPos > 0)
                play(currentPos);
            return true;
        case KeyEvent.KEYCODE_DPAD_RIGHT:
            currentPos = mVideoView.getCurrentPosition();
            currentPos += delta;
            if( currentPos < mDuration )
                play( currentPos );
            return true;
        case KeyEvent.KEYCODE_DPAD_UP:
            return true;
    }

    return super.onKeyDown(keyCode, event);
}

Let there be lips!
    And with that you can have a pretty solid media app for the AndroidTV. Now that the technical part is done, if you're not interested in some random ridiculousness, you're alright to stop reading now :)

    Still with me? Alright, cool. So earlier I added a preference square item to MainFragment, and when selected it launches an intent to SlothActivity. I wanted to play around with AndroidTV a bit and see if you can throw in a custom activity, and it turns out you can. With a bit of stylized changes to this project from Bakhtiyor Khodjayev from a couple years ago that originally displayed animated snowflakes falling, I threw together a great custom activity of a sloth putting in a hard days work. Sadly recording though ADB doesn't capture audio, and I removed the MP4 from the project because I didn't want the hassle of being busted for more copyright infringement than I'm (probably, who knows?) doing, but the original audio to this was Led Zeppelin's Boogie with Stu. Anywho, for your viewing pleasure, the custom SlothActivity


6 comments:

  1. I am getting below error while launching LeanBack Android TV application, pls can help me

    Logs -
    08-06 01:39:14.611: D/AndroidRuntime(13793): Shutting down VM
    08-06 01:39:14.628: E/AndroidRuntime(13793): FATAL EXCEPTION: main
    08-06 01:39:14.628: E/AndroidRuntime(13793): Process: com.example.android.leanback, PID: 13793
    08-06 01:39:14.628: E/AndroidRuntime(13793): java.lang.NoClassDefFoundError: Failed resolution of: Landroid/support/v17/leanback/R$styleable;
    08-06 01:39:14.628: E/AndroidRuntime(13793): at android.support.v17.leanback.app.BrowseFragment.onCreate(BrowseFragment.java:485)
    08-06 01:39:14.628: E/AndroidRuntime(13793): at android.app.Fragment.performCreate(Fragment.java:1682)

    ReplyDelete
    Replies
    1. What does your gradle file look like? And have you installed the Android L Developers Preview SDK?

      Delete
  2. I finally got it working by changing the build tools version to the latest.

    ReplyDelete
  3. What's the best way to keep track of (save) video progress so if someone leaves the app they can resume it later instead of having to start over?

    ReplyDelete
    Replies
    1. Store it in a SharedPreference, and when the video activity is created check if the preference exists and its value is greater than 0 for position, then restore that.

      Delete