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();
}
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 |
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! |
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