“接下来播放”行是一个由系统管理的频道,可供所有 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 文件的形式下载该代码库:
打开 Android Studio,从菜单栏中依次点击 File > Open,或者从启动画面中点击 Open an Existing Android Studio Project,然后选择最近克隆的文件夹。
运行入门级项目
尝试运行该项目。如果您遇到问题,请参阅关于使用入门的文档。
- 连接 Android TV 或启动模拟器。
- 选择 step_1 配置,然后按菜单栏中的 run 按钮。
- 选择您的 Android 设备,然后点击 OK。
- 您应该会看到两个类别(“Recommendations”和“Dramas”),每个类别包含 4 部电影。“Dramas”是随机的,因此电影的顺序可能会有所不同。
- 该应用的主屏幕中应该有一个默认频道。该默认频道为“Recommendations”类别。
将节目添加到“接下来播放”行,为此,您可以在“Recommendations”频道中长按要添加的节目,以便打开上下文菜单。
对于已添加到“接下来播放”行的节目,只需长按该节目,然后选择“remove from play next”菜单选项,即可将其移除。
step_1 是基本应用,后续的每个步骤都基于该应用。
在每个步骤中,您都需要向“step_1”应用添加更多代码。
另一个模块可用作检查点,在整个过程中的每个步骤中,您都可以将您的工作成果与对应的解决方案进行比较。“step_final”模块是完整应用,而其他 step_X 模块只是完成此 Codelab 中的相应步骤后的状态。
下面是该应用中的主要组件:
MainFragment
用于显示不同的电影类别。WatchNextTvProvider
和ChannelTvProviderFacade
是与 TV 内容提供程序进行交互的抽象化功能。- database/
MockDatabase
是一个模拟的本地电影/类别数据库。为简单起见,对于所有类别都使用同一个电影列表。这样做只是为了便于说明,真实应用会采用更多结构来组织内容。 - model/
Movie
用于存储电影的元数据。请注意,该数据库包含的电影其实只是剪辑。这也是为了简单起见。真实的应用绝不能将剪辑放在“接下来播放”行中,只有电影和电视节目才能放在该行中。 - model/
Category
用于存储类别的元数据。每个类别都包含一个电影列表。 PlaybackVideoFragment
用于播放电影。- watchlist/
WatchlistManager
用于管理用户控制的应用内容观看列表。
此 Codelab 使用的是 MediaPlayer
,但相关概念同样适用于 ExoPlayer
或任何提供当前位置、播放器状态变化以及播放完成时间的相关回调的播放器。
后续操作
在用户停止观看时添加“接下来播放”内容,以便其日后继续观看。
如果用户在节目结束前停止观看,您可以将相应节目添加到“接下来播放”行,以便用户日后观看。这是“接下来播放”的主要用途。该应用会将内容与进度指示器一起添加到“接下来播放”行。
首先,我们要看一看 PlaybackVideoFragment
,以了解该应用如何处理播放。
我们使用 MediaSessionCompat
和 PlaybackTransportControlGlue
来管理播放。在 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 种情况:
- 如果节目不存在于“接下来播放”行中,则添加节目。
- 如果节目已存在并已显示,则更新节目的条目。
- 如果节目存在,但未显示(由于用户已将其移除),您必须删除未显示的节目,然后重新将其添加到“接下来播放”行。
接下来,我们要在 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”视频。
转到 PlaybackVideoFragment
中 SyncWatchNextCallback
类中的 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。