与“接下来播放”行集成,在 Android TV 上提升互动度

“接下来播放”行是一个由系统管理的频道,可供所有 TV 应用使用。

它是收藏的应用下方的第一行,并且会显示在所有应用频道的前面。

您可以使用“接下来播放”行吸引用户保持互动。提供该频道并不是为了替换您的频道,而是为了显示直接相关的内容。

在学习此 Codelab 的过程中,您将了解在哪些不同的情况下,您应该将自己的应用添加到“接下来播放”行。此 Codelab 仅重点介绍“接下来播放”行。

如需详细了解如何创建您自己的频道,请参阅相关文档或尝试学习相关 Codelab

此 Codelab 介绍了如何在启动器屏幕上添加、更新或移除“接下来播放”行中的节目。

“接下来播放”行中的内容提供了多种功能,能够以不同方式提升用户体验。各项内容的行为可以按“接下来观看”类型进行汇总。有以下 4 种类型:

  • WATCH_NEXT_TYPE_CONTINUE 表示用户已开始观看内容。
  • WATCH_NEXT_TYPE_NEXT 表示内容是一个系列的下一部分,例如电视剧季中的下一集节目。
  • WATCH_NEXT_TYPE_WATCHLIST 表示用户已手动将内容添加到“观看列表”。在将内容从主屏幕添加到“接下来播放”行时,系统会使用该类型。
  • WATCH_NEXT_TYPE_NEW 表示内容是新内容。例如,用户观看的电视节目的新剧季上线。

此 Codelab 展示了如何使用前三种类型,而没有介绍 WATCH_NEXT_TYPE_NEW。

现在,您已经了解如何使用“接下来播放”行了,接下来,我们就下载项目,然后开始编码!

克隆入门级项目代码库

此 Codelab 使用 Android Studio

如果您尚未安装该软件,请下载并安装它。

您需要下载此 Codelab 的源代码。您可以从 GitHub 克隆代码库:

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

…或者,以 ZIP 文件的形式下载该代码库:

>下载 ZIP 文件

打开 Android Studio,从菜单栏中依次点击 File > Open,或者从启动画面中点击 Open an Existing Android Studio Project,然后选择最近克隆的文件夹。

运行入门级项目

尝试运行该项目。如果您遇到问题,请参阅关于使用入门的文档

  1. 连接 Android TV 或启动模拟器。
  1. 选择 step_1 配置,然后按菜单栏中的 run 按钮。
  2. 选择您的 Android 设备,然后点击 OK
  3. 您应该会看到两个类别(“Recommendations”和“Dramas”),每个类别包含 4 部电影。“Dramas”是随机的,因此电影的顺序可能会有所不同。

  1. 该应用的主屏幕中应该有一个默认频道。该默认频道为“Recommendations”类别。

将节目添加到“接下来播放”行,为此,您可以在“Recommendations”频道中长按要添加的节目,以便打开上下文菜单。

对于已添加到“接下来播放”行的节目,只需长按该节目,然后选择“remove from play next”菜单选项,即可将其移除。

step_1 是基本应用,后续的每个步骤都基于该应用。

在每个步骤中,您都需要向“step_1”应用添加更多代码。

另一个模块可用作检查点,在整个过程中的每个步骤中,您都可以将您的工作成果与对应的解决方案进行比较。“step_final”模块是完整应用,而其他 step_X 模块只是完成此 Codelab 中的相应步骤后的状态。

下面是该应用中的主要组件:

  • MainFragment 用于显示不同的电影类别。
  • WatchNextTvProviderChannelTvProviderFacade 是与 TV 内容提供程序进行交互的抽象化功能。
  • database/ MockDatabase 是一个模拟的本地电影/类别数据库。为简单起见,对于所有类别都使用同一个电影列表。这样做只是为了便于说明,真实应用会采用更多结构来组织内容。
  • model/ Movie 用于存储电影的元数据。请注意,该数据库包含的电影其实只是剪辑。这也是为了简单起见。真实的应用绝不能将剪辑放在“接下来播放”行中,只有电影和电视节目才能放在该行中。
  • model/ Category 用于存储类别的元数据。每个类别都包含一个电影列表。
  • PlaybackVideoFragment 用于播放电影。
  • watchlist/WatchlistManager 用于管理用户控制的应用内容观看列表。

此 Codelab 使用的是 MediaPlayer,但相关概念同样适用于 ExoPlayer 或任何提供当前位置、播放器状态变化以及播放完成时间的相关回调的播放器。

后续操作

在用户停止观看时添加“接下来播放”内容,以便其日后继续观看。

如果用户在节目结束前停止观看,您可以将相应节目添加到“接下来播放”行,以便用户日后观看。这是“接下来播放”的主要用途。该应用会将内容与进度指示器一起添加到“接下来播放”行。

首先,我们要看一看 PlaybackVideoFragment,以了解该应用如何处理播放。

我们使用 MediaSessionCompatPlaybackTransportControlGlue 来管理播放。在 PlaybackVideoFragment.onCreate() 中,我们将回调函数 SyncWatchNextCallback 添加到传输控件粘合剂。

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))
       // ...
   }
   // ...
}

SyncWatchNextCallback 类可以控制“接下来播放”行中的内容。系统会在播放发生状态变化或完成时调用该类。

在播放状态发生变化时更新“接下来播放”行

您必须在 SyncWatchNextCallback 类中实现两种方法。首先,我们来实现 onPlayStateChange() 方法。

借助 onPlayStateChange() 方法,我们可以确定视频是已暂停还是已恢复播放。如果视频已暂停,我们应根据视频的当前位置来更新“接下来播放”行。与使用定时器或者依赖 fragment 的 onStop()onPause() 相比,使用该回调方法更准确,因为它可以提供播放器的当前状态。

更新该方法以在后台安排作业,从而将视频添加到“接下来播放”行。在 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)
   }
}

scheduleAddToWatchNextContinue() 方法会为您安排 JobService。您需要更新 WatchNextTvProvider 才能将视频添加到“接下来播放”行。若要添加视频,您应该验证相应视频目前并不存在于“接下来播放”行中。在下一部分中,我们将详细介绍如何添加视频以及如何防止出现重复视频。

播放后清理

务必在内容播放完毕后,从“接下来播放”行中将其移除。应用在播放完毕后自行清理,有助于建立用户信任。

在退出回调之前,我们先安排一项作业,以便在播放完毕后移除相应节目。在 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 函数一样简单。如果内容每暂停一次就添加一次,相应内容就可能会在“接下来播放”行中出现多次,而这并不是我们想要的效果。此外,如上所述,用户可以从该行中移除内容(此操作可隐藏相应内容)。相应内容需要重新显示。在添加内容之前,必须先执行一些检查。有 3 种情况:

  1. 如果节目不存在于“接下来播放”行中,则添加节目。
  2. 如果节目已存在并已显示,则更新节目的条目。
  3. 如果节目存在,但未显示(由于用户已将其移除),您必须删除未显示的节目,然后重新将其添加到“接下来播放”行。

接下来,我们要在 WatchNextTvProvider.addToWatchNextRow() 方法中实现这一逻辑。首先,要收集我们需要的基本信息:

  • 节目是否存在于“接下来播放”行中?
  • 是否已显示?

TODO: Step 3 下方的以下代码复制到 addToWatchNextRow() 方法中。

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.
   // ...
}

我们需要实现 findProgramByMovieId()removeIfNotBrowsable()

查找节目

findProgramByMovieId() 开始,我们要在主屏幕的数据库中查询“接下来观看”节目。内容提供程序会返回我们的应用已添加的所有节目。

电影 ID 位于 TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID 列中,使其能够将应用数据与主屏幕上显示的内容相关联。

TODO: Step 4 下方的以下代码复制到 findProgramByMovieId() 方法中。

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
}

视需要进行移除

如果节目存在但未显示,我们需要将其移除。数据库中表示节目是否显示的列称为 COLUMN_BROWSABLE。由于我们要将光标读入对象,因此我们可以对照节目的 isBrowsable() 方法进行检查。

TODO: Step 5 下方的以下代码复制到 removeIfNotBrowsable() 方法中。

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
}

现在,我们可以完成 addToWatchNextRow() 方法了。具体而言,我们需要:

  • 根据 WatchNextProgram.Builder 创建内容值
  • 设置“接下来观看”类型
  • 更新自用户与节目直接互动以来的上次互动时长
  • 设置播放位置

创建内容值

我们需要创建要存储在主屏幕数据库中的内容值。如果是要更新节目,我们可以重复使用现有节目中的值。

然后,我们要更新“接下来观看”类型,使其更准确。将类型设为 WATCH_NEXT_TYPE_CONTINUE,即可在界面中启用进度指示器。

更新上次互动时间,即可将节目的优先级提升为在列表的前端显示。

最后,我们要设置播放位置,以便界面可以呈现准确的进度指示器。

TODO: Step 6 下方的以下代码复制到 addToWatchNextRow() 方法中。

// 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()

更新或添加节目

最后,我们应该更新节目,或将节目插入内容提供程序。

TODO: Step 7 下方的以下代码复制到 addToWatchNextRow() 方法中。

// 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)
}

运行应用

开始播放“Rushmore”视频,并随时返回主屏幕查看视频是否显示在“接下来播放”行中。视频播放完毕后,节目应该会消失。

将代码与 step_2 模块中的解决方案进行比较。

您学到的内容

  • 如何使用回调让 MediaPlayer 监听播放状态变化。
  • 如何同步“接下来播放”行中的内容。
  • 如何在播放后自行进行清理。

后续操作

下一部分将详细介绍如何将一个系列中的下一段内容添加到“接下来播放”行,从而吸引用户保持互动。

“接下来播放”行是吸引用户与剧集内容互动的绝佳方式。在当前视频播放完毕后,您可以将系列中的下一个节目添加到“接下来播放”行。

Movie 数据类中有一个指向系列中的下一部电影的链接。

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

此 Codelab 包含与“Explore Treasure Mode with Google Maps”相关联的“Rushmore”视频。在 MockDatabase 中,您可以看到这两部电影之间的关联方式。

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

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

在“Rushmore”视频播放完毕后,“Explore Treasure Mode with Google Maps”视频应显示在“接下来播放”行中,并且系统应移除“Rushmore”视频。

转到 PlaybackVideoFragmentSyncWatchNextCallback 类中的 onPlayCompleted() 方法。

TODO: Step 8 备注后添加以下代码。这些代码会在后台安排 JobService 以添加电影。

// 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)
   }
}

当您运行该应用并观看“Rushmore”视频时,系统应将下一个视频添加到“接下来播放”行。不过,元数据会显示“Resume watching”。这是因为未正确设置“接下来观看”类型。

WatchNextTvProvider 中,将构建器更改为使用所提供的“接下来观看”类型。将“接下来观看”类型更改为 watchNextType,而非 TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE

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

由于用于向“接下来播放”行添加内容的代码是相同的(与类型无关),因此,您可以将“接下来观看”类型传递到相应方法中。

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)

运行应用

将“Rushmore”视频看完。此时应该显示“Explore Treasure Mode with Google Maps”视频,以及表示该视频就是用户要观看的下一个视频的元数据。

将代码与 step_3 目录中的解决方案进行比较。

您学到的内容

  • 如何在视频播放完毕后将其他节目添加到“接下来播放”行
  • “接下来观看”类型如何影响元数据。

后续操作

在用户选择内容后,主屏幕会告知您的应用。下一部分将介绍您的应用可以怎样处理用户互动,从而改进应用行为。

当用户操控频道和“接下来播放”行中的节目时,主屏幕会广播相应事件。您的应用可以监听这些事件并相应地做出响应。

此 Codelab 重点介绍与“接下来播放”行相关的两项操作,但还有其他类型的操作会与主屏幕相关联。

应用的观看列表

此 Codelab 管理着一个观看列表,用户可向其中添加以后要观看的电影。如果您将某部电影添加到观看列表,该电影也会显示在“接下来播放”行中,其类型为 WATCH_NEXT_TYPE_WATCHLIST。该类型与在将频道中的节目添加到“接下来播放”行时系统使用的类型相同。如需详细了解观看列表的工作原理,请参阅 WatchlistManager 类。

在电影详细信息屏幕中,将电影添加到观看列表。返回主屏幕,此时,相应电影就会显示在“接下来播放”行中。

该应用应该监听来自主屏幕的事件,并相应地更新观看列表。

从观看列表中移除视频

BroadcastReceiver 会监听主屏幕发出的 intent。在收到 intent 后,系统会使用 extras 中的键 EXTRA_WATCH_NEXT_PROGRAM_ID 提供节目 ID,以便接收器查找相关联的电影。

修改 WatchNextNotificationReceiver 类,以从 intent 的 extras 中获取节目 ID。在 TODO: Step 9 下方添加以下代码。

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

从“接下来播放”行中移除节目后 (ACTION_WATCH_NEXT_PROGRAM_BROWSABLE_DISABLED),使用“接下来观看”节目 ID 查找相应电影,并将其从观看列表中移除。

TODO: Step 10 后面的 when 表达式中添加以下代码。

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.
}

将视频添加到应用的观看列表

该应用也可以监听相反的事件。如果用户通过主屏幕将某部电影添加到“接下来播放”行,该应用可以将相应电影添加到观看列表。

当用户将某个节目添加到“接下来播放”行后,系统会发送包含“接下来观看”节目 ID 和频道中的节目 ID 的 intent。该应用会使用节目 ID 来查找要添加到应用观看列表的相应电影。

TODO: Step 11 后面的 when 表达式中添加以下代码。

// 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)
           }
}

运行应用

运行应用时,您可以通过在“接下来播放”行中添加和移除电影来维护应用的观看列表。

将代码与 step_final 目录中的解决方案进行比较。

您学到的内容

  • 主屏幕上的用户操作如何触发可影响“接下来播放”行的事件。
  • 如何将应用的内部数据与存储在系统中的数据同步。

恭喜!

您已完成此 Codelab,现在,您已成为“接下来播放”行方面的专家!

如需了解详情,请访问相关文档、查看相关示例,或完成有关频道和计划的 Codelab