Raise engagement on Android TV by integrating with the Play Next row

The Play Next row is a system-managed channel that is shared by all TV apps.

It's the first row under favorite apps and appears before any app's channel.

Use the Play Next row to keep users engaged. It is not meant to replace your channels but rather to display content that's immediately relevant .

As you progress through this codelab, you will learn about the different scenarios when your app should add to the Play Next row. This codelab focuses only on the Play Next row.

To learn more about how to create your own channel, check out the documentation or try the codelab.

This codelab shows how to add, update, and remove programs from the Play Next row on the launcher screen.

The items in the Play Next row provide several functions that enhance the user experience in different ways. The available behaviors can be summarized by the watch next type for each item. There are four types:

  • WATCH_NEXT_TYPE_CONTINUE indicates that a user has started watching content.
  • WATCH_NEXT_TYPE_NEXT indicates that the content is the next part of a series, for example next TV show in a season.
  • WATCH_NEXT_TYPE_WATCHLIST means the user has manually added content to a ‘watchlist'. The system uses this type when adding to the Play Next row from the home screen.
  • WATCH_NEXT_TYPE_NEW means that the content is new. For example, a new season of a TV show that the user watches is released.

This codelab shows how to use the first three types. WATCH_NEXT_TYPE_NEW is not covered here.

Now that you understand how to use the Play Next row, let's download a project and start coding!

Clone the starter project repo

This codelab uses Android Studio.

If you don't have it installed yet, please download and install it.

You need to download the source code for this codelab. You can either clone the repository from Github:

git clone https://github.com/googlecodelabs/tv-watchnext.git

...or you can download the repository as a zip file:

>DOWNLOAD ZIP

Open Android Studio and click File > Open from the menu bar or Open an Existing Android Studio Project from the splash-screen and select the recently cloned folder.

Run the starter project

Try running the project. If you have issues, see our documentation on how to get started.

  1. Connect your Android TV or start the emulator.
  1. Select the step_1 configuration and press the run button in the menu bar.
  2. Select your Android device and click OK.
  3. You should see two categories (Recommendations and Dramas) with four movies in each. The Dramas are randomized so the order of the movies may be different.

  1. There should be a default channel for this app on the home screen. The default channel is the "Recommendations" category.

Add a program to the Play Next row by long pressing on a program to open the context menu in the Recommendations channel.

Once a program is in the Play Next row, it can be removed by long pressing and select the "remove from play next" menu option.

step_1 is the base app that each successive step is based on.

In each step, you'll add more and more code to the step_1 app.

The other modules can be used as checkpoints to compare your work with the solution at each step along the way. The step_final module is the complete app whereas the other step_X modules are only completed up to their respective step in the codelab.

These are the main components in the app:

  • MainFragment displays the different categories of movies.
  • WatchNextTvProvider and ChannelTvProviderFacade are abstractions that interact with the TV content provider.
  • database/ MockDatabase is a mock local movie/category database. For simplicity, the same list of movies is used for all of the categories. This is done for illustrative purposes only, a real app would organize its content with more structure.
  • model/ Movie stores metadata about a movie. Note that the database includes movies that are really just clips. This is also for simplicity. A real app should never put clips in the Play Next row, only movies and TV shows.
  • model/ Category stores metadata about a category. Each category contains a list of movies.
  • PlaybackVideoFragment plays movies.
  • watchlist/WatchlistManager manages a user controlled watch list of content from the app.

This codelab uses MediaPlayer but the same concepts can be applied to ExoPlayer or any player that supplies callbacks for current position, change in player state, and when playback completes.

What's next?

Add a Play Next item when the user stops watching so they can continue watching later.

When the user stops watching a show before it ends, you can add it to the Play Next row to allow future viewing. This is the primary use of Play Next. The app adds the content into the Play Next row with a progress indicator.

Let's first look at the PlaybackVideoFragment to understand how the app handles playback.

We use MediaSessionCompat and PlaybackTransportControlGlue to manage playback. In PlaybackVideoFragment.onCreate(), we add the callback function, SyncWatchNextCallback to the transport control glue.

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   // ...
   val glueHost = VideoSupportFragmentGlueHost(this@PlaybackVideoFragment)

   playerGlue = PlaybackTransportControlGlue(context, MediaPlayerAdapter(context)).apply {
       host = glueHost
       // Set the callback on the player glue
       addPlayerCallback(SyncWatchNextCallback(context, movie))
       // ...
   }
   // ...
}

The SyncWatchNextCallback class controls the content in the Play Next row. It is called when the playback state changes or completes.

Update Play Next when the playback state changes

You must implement two methods in the SyncWatchNextCallback class. Let's start with the onPlayStateChange() method.

The onPlayStateChange() method lets us know if the video has paused or resumed. If the video has paused, we should update the Play Next row with the current position of the video. Using this callback method is more accurate than using a timer or relying on onStop() or onPause()of the fragment since it supplies the current state of the player.

Update the method to schedule a job in the background to add the video to the Play Next row. Add the following code after the TODO: Step 1.

override fun onPlayStateChanged(glue: PlaybackGlue) {
   // TODO: Step 1 - Update the Play Next row when the video is paused.
   if (!glue.isPlaying) {
       val controlGlue = glue as PlaybackTransportControlGlue<*>
       // Get the current position to update the progress bar in the UI.
       val playbackPosition = controlGlue.playerAdapter.currentPosition.toInt()
       // Schedule the video to be added in a background job.
       scheduleAddToWatchNextContinue(context, movie, playbackPosition)
   }
}

The scheduleAddToWatchNextContinue() method schedules a JobService for you. You need to update the WatchNextTvProvider to add the video to the Play Next row. In order to add a video, you should verify that the video does not exist in the Play Next row already. In the next section we break down how to add a video and prevent duplicates from appearing.

Clean up after playing

Always remove content from the Play Next row when playback completes. When your app cleans up after itself, it helps build user trust.

Before we leave the callback, let's schedule a job to remove the program once playback completes. Add the following code after the TODO: Step 2.

override fun onPlayCompleted(glue: PlaybackGlue) {
   // TODO: Step 2 - Schedule remove the program from the Play Next row.
   scheduleRemoveFromWatchNextContinue(context = context, movie = movie)
}

Add content to the Play Next row

Adding content to the Play Next row is not as trivial as calling an add function. If content is added each time it is paused, it may appear multiple times in the Play Next row which is not ideal. In addition, as mentioned above, a user can remove an item from the row which will hide it. The item needs to become visible again. Before content is added, a few checks must be performed. There are three cases:

  1. If the program does not exist in the Play Next row, then add it.
  2. If the program exists and it is visible, update its entry.
  3. If the program exists but it's invisible (because the user removed it) you must delete the invisible program and add it back again to the Play Next row.

Let's implement this logic in the WatchNextTvProvider.addToWatchNextRow() method. Start by gathering the basic information we need:

  • Does the program exist in the Play Next row?
  • Is it visible?

Copy the following code below the TODO: Step 3 into the addToWatchNextRow() method.

private fun addToWatchNextRow(
       context: Context,
       movie: Movie,
       @TvContractCompat.WatchNextPrograms.WatchNextType watchNextType: Int,
       playbackPosition: Int? = null): Long {

   val movieId = movie.movieId.toString()
   
   // TODO: Step 3 - find the existing program, see if it has been
   // removed, and check if we should update the program.

   // Check if the movie is in the watch next row.
   val existingProgram = findProgramByMovieId(context, movieId)

   // If the program is not visible, remove it from the Tv Provider, and treat the movie as a new watch next program.
   val removed = removeIfNotBrowsable(context, existingProgram)

   val shouldUpdateProgram = existingProgram != null && !removed

   // TODO: Step 6 - Create the content values for the Content Provider.
   // ...
}

We need to implement findProgramByMovieId() and removeIfNotBrowsable().

Finding a program

Starting with findProgramByMovieId(), we want to query the home screen's database for the watch next programs. The content provider returns all programs that our app has added.

The movie's id is in the TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID column so it can link the app's data with what is shown on the home screen.

Copy the following code below TODO: Step 4 in the findProgramByMovieId() method.

private fun findProgramByMovieId(context: Context, movieId: String): WatchNextProgram? {
   // TODO: Step 4 - Find the movie by our app's internal id.
   context.contentResolver
       .query(TvContractCompat.WatchNextPrograms.CONTENT_URI, WATCH_NEXT_MAP_PROJECTION,
               null, null, null, null)
       ?.use { cursor ->
           if (cursor.moveToFirst()) {
               do {
                   val watchNextInternalId =
                       cursor.getString(COLUMN_WATCH_NEXT_INTERNAL_PROVIDER_ID_INDEX)
                   if (movieId == watchNextInternalId) {
                       return WatchNextProgram.fromCursor(cursor)
                   }
               } while (cursor.moveToNext())
           }
       }
   return null
}

Remove when necessary

If the program exists but is not visible, we need to delete it. The column in the database that represents if a program is visible is called COLUMN_BROWSABLE. Since we read the cursor into an object, we can check against the program's isBrowsable() method.

Copy the following code below TODO: Step 5 in the removeIfNotBrowsable() method.

private fun removeIfNotBrowsable(context: Context, program: WatchNextProgram?): Boolean {
   // TODO: Step 5 - Check if a program has been removed from the UI by the user. 
   // If so, then remove the program from the content provider.
   if (program?.isBrowsable == false) {
       val watchNextProgramId = program.id
       val rowsDeleted = context.contentResolver.delete(
           TvContractCompat.buildWatchNextProgramUri(watchNextProgramId),
           null, null)
       return true
   }
   return false
}

Now we can complete the addToWatchNextRow() method. Specifically we need to:

  • Create the content values from the WatchNextProgram.Builder
  • Set the watch next type
  • Update the last engagement time since the user had immediately interacted with the program
  • Set the playback position

Creating content values

We need to create the content values to be stored in the home screen's database. If we are updating the program, we can reuse the values from the existing program.

We then update the watch next type to be more accurate. Setting the type to WATCH_NEXT_TYPE_CONTINUE enables the progress indicator in the UI.

Updating the last engagement time raises the priority of the program to be shown at the front of the list.

Lastly, we set the playback position so that the UI can render an accurate progress indicator.

Copy the following code below TODO: Step 6 into the addToWatchNextRow() method.

// TODO: Step 6 - Create the content values for the Content Provider.

val builder = if (shouldUpdateProgram) {
   WatchNextProgram.Builder(existingProgram)
} else {
   convertMovie(movie)
}

// Update the Watch Next type since the user has explicitly asked for the movie to be added to the Play Next row.
// TODO: Step 9 Update the watch next type.
builder.setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
       .setLastEngagementTimeUtcMillis(System.currentTimeMillis())
if (playbackPosition != null) {
   builder.setLastPlaybackPositionMillis(playbackPosition)
}

val contentValues = builder.build().toContentValues()

Update or add the program

Lastly, we should either update or insert the program into the content provider.

Copy the following code below TODO: Step 7 into the addToWatchNextRow() method.

// TODO: Step 7 - Update or add the program to the content provider.
if (shouldUpdateProgram) {
   val program = existingProgram as WatchNextProgram
   val watchNextProgramId = program.id
   val watchNextProgramUri = TvContractCompat.buildWatchNextProgramUri(watchNextProgramId)
   val rowsUpdated = context.contentResolver.update(
           watchNextProgramUri, contentValues, null, null)
   if (rowsUpdated < 1) {
       Log.e(TAG, "Failed to update watch next program $watchNextProgramId")
       return -1L
   }
   return watchNextProgramId
} else {
   val programUri = context.contentResolver.insert(
           TvContractCompat.WatchNextPrograms.CONTENT_URI, contentValues)

   if (programUri == null || programUri == Uri.EMPTY) {
       Log.e(TAG, "Failed to insert movie, $movieId, into the watch next row")
   }
   return ContentUris.parseId(programUri)
}

Run the app

Start playing the Rushmore video and at any point return home to see the video appear in the Play Next row. When the video completes, the program shall disappear.

Compare your code to the solution in the step_2 module.

What you've learned

  • How to use callbacks for MediaPlayer to listen to playback state changes.
  • How to synchronize content in the Play Next Row.
  • How to clean up after yourself.

What's next?

The next section goes into more detail on how to keep users engaged by adding the next piece of content in a series to the Play Next row.

The Play Next row is a great way to keep users engaged with episodic content. The next show in a series can be added to the Play Next row when the current video finishes playback.

In the Movie data class there is a link to the next movie in the series.

data class Movie @JvmOverloads constructor(
        var movieId: Long = 0,
        // ...
        val nextMovieIdInSeries: Long? = -1L) : Parcelable {
  // ...
}

The codelab has the "Rushmore" video linked with the "Explore Treasure Mode with Google Maps". In the MockDatabase, you can see how the two movies are linked.

private val rushmore: Movie
   get() = Movie(
           movieId = 3L,
           // ...
           nextMovieIdInSeries = treasureMode.movieId
   )

private val treasureMode: Movie
   get() = Movie(
           movieId = 4L,
           // ...
   )

Upon the completion of the "Rushmore" video, the "Explore Treasure Mode with Google Maps" video should appear in the Play Next row and the "Rushmore" video should be removed.

Go to the onPlayCompleted() method in the SyncWatchNextCallback class in PlaybackVideoFragment.

Add the following code after the TODO: Step 8 comment. This code schedules a JobService in the background to add the movie.

// TODO: Step 8 - Schedule the next video to be added to the Play Next row.
movie.nextMovieIdInSeries?.let { id ->
   if (id > -1L) {
       scheduleAddingToWatchNextNext(context = context, movieId = id)
   }
}

When you run the app and watch the "Rushmore" video, the next video should be added to the Play Next row. However, the metadata says "Resume watching". This is because the watch next type is not set appropriately.

In the WatchNextTvProvider change the builder to use the supplied watch next type. Change the watch next type to be watchNextType instead of TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE.

// TODO: Step 9 Update the watch next type.
builder.setWatchNextType(watchNextType)
   .setLastEngagementTimeUtcMillis(System.currentTimeMillis())

Since the code for adding to the Play Next row is the same regardless of the type, you can pass the watch next type into the method.

fun addToWatchNextNext(context: Context, movie: Movie): Long =
   addToWatchNextRow(
           context, 
           Movie,
           TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)

fun addToWatchNextContinue(context: Context, movie: Movie, playbackPosition: Int): Long =
   addToWatchNextRow(
           context,
           movie,
           TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE,
           playbackPosition)

Run the app

Watch the "Rushmore" video to the end. The "Explore Treasure Mode with Google Maps" video should appear with metadata indicating that it is the next video for the user to watch.

Compare your code to the solution in the step_3 directory.

What you've learned

  • How to add another program to the Play Next row when a video completes playback
  • How the watch next type affects the metadata.

What's next?

The home screen lets your app know when users select content. The next section shows how your app can handle the user interaction to improve your app's behavior.

The home screen broadcasts events when users manipulate programs in channels and the Play Next row. Your app can listen for these events and respond accordingly.

This codelab focuses on the two actions related to the Play Next row but there are other types of actions associated with the home screen.

The app's watchlist

The codelab manages a watchlist that users can add movies to be watched later. When you add a movie to the watchlist, it also appears in the Play Next row with type WATCH_NEXT_TYPE_WATCHLIST. This is the same type that the system uses when adding a program from a channel to the Play Next row. To learn more about how the watchlist works, read the WatchlistManager class.

From the movie details screen, add a movie to the watchlist. Go back to the home screen and the movie appears in the Play Next row.

The app should listen to the events from the home screen and update the watchlist accordingly.

Remove videos from the watchlist

A BroadcastReceiver listens to intents emitted by the home screen. When it receives an intent, the program id is provided with the key EXTRA_WATCH_NEXT_PROGRAM_ID in the extras for the receiver to find the associated movie.

Modify the WatchNextNotificationReceiver class to get the program id from the extras of the intent. Add the following code below TODO: Step 9.

// TODO: Step 10 extract the EXTRA_WATCH_NEXT_PROGRAM_ID
val watchNextProgramId = extras.getLong(TvContractCompat.EXTRA_WATCH_NEXT_PROGRAM_ID)

When the program is removed from the Play Next row (ACTION_WATCH_NEXT_PROGRAM_BROWSABLE_DISABLED), find the movie using the watch next program id and remove it from the watchlist.

Add the following code to the when expression after TODO: Step 10.

when(intent.action) {
   // TODO: Step 11 remove the movie from the watchlist.

   // A program has been removed from the watch next row.
   TvContractCompat.ACTION_WATCH_NEXT_PROGRAM_BROWSABLE_DISABLED -> {
       Log.d(TAG, "Program removed from watch next watch-next: $watchNextProgramId")

       database.findAllMovieProgramIds(context)
               .find { it.watchNextProgramId == watchNextProgramId }
               ?.apply {
                   watchlistManager.removeMovieFromWatchlist(context, movieId)
               }
   }
   // TODO: Step 12 add the movie to the watchlist.
}

Add videos to the app's watchlist

The app can also listen for the opposite. If a user adds a movie to the Play Next row from the home screen, the app can add that movie to the watchlist.

When a user adds a program to the Play Next row, the system sends an intent with both the watch next program id and the program id from the channel. The app uses the program id to find the corresponding movie to add to the app's watchlist.

Add the following code to the when expression after TODO: Step 11.

// TODO: Step 12 add the movie to the watchlist.
TvContractCompat.ACTION_PREVIEW_PROGRAM_ADDED_TO_WATCH_NEXT -> {

   val programId = extras.getLong(TvContractCompat.EXTRA_PREVIEW_PROGRAM_ID)

   Log.d(TAG,
           "Preview program added to watch next program: $programId watch-next: $watchNextProgramId")

   database.findAllMovieProgramIds(context)
           .find { it.programIds.contains(programId) }
           ?.apply {
               watchlistManager.addToWatchlist(context, movieId)
           }
}

Run the app

When you run the app, you are able to maintain the app's watchlist by adding and removing movies from the Play Next row.

Compare your code to the solution in the step_final directory.

What you've learned

  • How user actions on the home screen trigger events that affect Play Next row.
  • How to synchronize an app's internal data with data that's stored in the system.

Congratulations!

You have completed the codelab and are now an expert with the Play Next row!

To learn more, visit the documentation, check out the sample, or complete the codelab on channels and programs.