支持 Android TV 应用的投射

1. 概览

Google Cast 徽标

在此 Codelab 中,您将学习如何修改现有 Android TV 应用,以便支持从现有的 Cast 发送器应用进行投射和通信。

什么是 Google Cast 和 Cast Connect?

Google Cast 让用户可以将移动设备上的内容投射到电视上。典型的 Google Cast 会话由以下两个组件构成:发送器应用和接收器应用。发送器应用(例如移动应用或 YouTube.com 等网站)会启动和控制 Cast 接收器应用的播放功能。Cast 接收器应用是可在 Chromecast 和 Android TV 设备上运行的 HTML 5 应用。

Cast 会话中的所有状态几乎都存储在接收器应用中。当状态更新时(例如,加载了新的媒体内容),系统就会以广播的形式向所有发送器发送一条媒体状态通知。这些广播中包含 Cast 会话的当前状态。发送器应用将使用此媒体状态,在其界面中显示播放信息。

Cast Connect 在此基础架构之上构建,并以 Android TV 应用作为接收器。通过使用 Cast Connect 库,您的 Android TV 应用就可以如同 Cast 接收器应用一般接收信息和广播媒体状态。

构建目标

完成此 Codelab 之后,您将可以使用 Cast 发送器应用将视频投射到 Android TV 应用中。Android TV 应用还可以通过 Cast 协议与发送器应用通信。

学习内容

  • 如何将 Cast Connect 库添加到示例 ATV 应用。
  • 如何连接 Cast 发送器和启动 ATV 应用。
  • 如何通过 Cast 发送器应用在 ATV 应用中启动媒体播放。
  • 如何从 ATV 应用将媒体状态发送到 Cast 发送器应用。

所需条件

2. 获取示例代码

您可以将所有示例代码下载到您的计算机…

解压缩下载的 zip 文件。

3. 运行示例应用

首先,我们来看看完成后的示例应用的外观。Android TV 应用使用 Leanback 界面和一个基本的视频播放器。用户可以从列表中选择视频,选择的视频将在电视上进行播放。若使用配套的手机发送器应用,用户还可以将视频投射到 Android TV 应用中。

在视频全屏预览上方叠加的一系列视频缩略图(其中突出显示了一张),图片“Cast Connect”出现在右上角

注册开发者设备

为了使 Cast Connect 功能可用于应用开发,您必须在 Cast Developer Console 中注册 Android TV 设备内置的 Chromecast 的序列号。您可以在 Android TV 上通过以下方法查找该序列号:设置 > 设备首选项 > 内置 Chromecast > 序列号。请注意,该序列号与物理设备的序列号不同,必须通过上述方法获取。

Android TV 屏幕的图片,其中显示了“内置 Chromecast”屏幕、版本号和序列号

如果未注册,出于安全原因,Cast Connect 将仅适用于从 Google Play 商店安装的应用。启动注册过程 15 分钟后,重启设备。

安装 Android 发送器应用

为了测试来自移动设备的发送请求,我们在源代码 zip 下载中提供了一个名为“投射视频”的简单的发送器文件,格式为 mobile-sender-0629.apk 文件。我们将使用 adb 安装 APK。如果您已经安装了其他版本的 Cast Videos,请先从设备上的所有个人资料中卸载该版本,然后再继续操作。

  1. 在 Android 手机上启用开发者选项和 USB 调试
  2. 插入 USB 数据线,将 Android 手机与开发计算机相连接。
  3. mobile-sender-0629.apk 安装到您的 Android 手机上。

运行 adb install 命令来安装 mobile-sender.apk 的终端窗口的图片

  1. 您可以在 Android 手机上找到投射视频发送器应用。投射视频发送者应用图标

在 Android 手机屏幕上显示的 Cast Videos 发送者应用的图片

安装 Android TV 应用

下面介绍如何在 Android Studio 中打开和运行已完成的示例应用:

  1. 在欢迎屏幕上选择 Import Project,或依次选择 File > New > Import Project... 菜单选项。
  2. 从示例代码文件夹中选择 文件夹图标app-done 目录,然后点击“OK”。
  3. 点击 File > Android App Studio 的“使用 Gradle 同步项目”按钮 Sync Project with Gradle Files
  4. 在您的 Android TV 设备上启用开发者选项和 USB 调试
  5. adb 与您的 Android TV 设备连接,该设备应显示在 Android Studio 中。显示在 Android Studio 工具栏上的 Android TV 设备的图片
  6. 点击 Android Studio 的“Run”按钮,一个指向右侧的绿色三角形Run 按钮,您应该会在几秒钟后看到名为 Cast Connect Codelab 的 ATV 应用。

玩转 Cast Connect 与 ATV 应用

  1. 转到 Android TV 主屏幕。
  2. 打开 Android 手机上的 Cast Videos 发送器应用。点击“投射”按钮 “投射”按钮图标,然后选择您的 ATV 设备。
  3. Cast Connect Codelab ATV 应用将在您的 ATV 上启动,且发送者的“投射”按钮会指示其已连接 颜色反转的“投射”按钮图标
  4. 从 ATV 应用中选择一个视频,该视频将会在您的 ATV 上开始播放。
  5. 在手机上,您的发送器应用的底部此时会显示一个迷你控制器。您可以使用播放/暂停按钮来控制播放。
  6. 从手机中选择并播放一个视频。视频将在 ATV 上开始播放,展开后的控制器会显示在手机发送器应用上。
  7. 锁定手机后进行解锁时,您应该会在锁定屏幕上看到一条可用于控制媒体播放或停止投射的通知。

Android 手机屏幕上某一部分的图片,其中有一个迷你播放器正在播放视频

4. 准备启动项目

现在,我们已验证完成后的应用对 Cast Connect 的集成,接下来需要向您下载的启动应用中添加 Cast Connect 支持。现在,您可以使用 Android Studio 在入门级项目的基础上进行构建了:

  1. 在欢迎屏幕上选择 Import Project,或依次选择 File > New > Import Project... 菜单选项。
  2. 从示例代码文件夹中选择 文件夹图标app-start 目录,然后点击“OK”。
  3. 点击 File > Android Studio 的“使用 Gradle 同步项目”按钮 Sync Project with Gradle Files
  4. 选择 ATV 设备,然后点击 Android Studio 的“Run”按钮,一个指向右侧的绿色三角形Run 按钮以运行应用并探索界面。显示所选 Android TV 设备的 Android Studio 工具栏

在视频全屏预览上方叠加的一系列视频缩略图(其中突出显示了一张),图片“Cast Connect”出现在右上角

应用设计

此应用可提供供用户浏览的视频列表。用户可以选择要在 Android TV 上播放的视频。此应用包含两个主要 activity:MainActivityPlaybackActivity

MainActivity

此 activity 包含一个 fragment (MainFragment)。系统会在 MovieList 类中配置视频列表及其关联的元数据,并调用 setupMovies() 方法来构建 Movie 对象列表。

Movie 对象表示包含标题、说明、图片缩略图和视频网址的视频实体。每个 Movie 对象都绑定到 CardPresenter,以显示带有标题和工作室的视频缩略图,并传递给 ArrayObjectAdapter

选择某一项后,对应的 Movie 对象会被传递到 PlaybackActivity

PlaybackActivity

此 activity 包含一个 fragment (PlaybackVideoFragment),用于托管一个带有 ExoPlayerVideoView、一些媒体控件,以及一个用于展示对选定视频的说明的文本区域,用户通过使用它可在 Android TV 上播放视频。用户可以使用遥控器来播放/暂停视频或者跳转视频播放。

Cast Connect 的前提条件

Cast Connect 使用了新版本的 Google Play 服务,这要求将 ATV 应用更新为使用 AndroidX 命名空间。

如需在 Android TV 应用中支持 Cast Connect,您必须通过媒体会话创建和支持事件。Cast Connect 库会根据媒体会话的状态生成媒体状态。Cast Connect 库还会使用媒体会话,在收到发送器发送来的特定信息(例如暂停)时发出信号。

5. 配置 Cast 支持

依赖项

更新应用的 build.gradle 文件以包含必要的库依赖项:

dependencies {
    ....

    // Cast Connect libraries
    implementation 'com.google.android.gms:play-services-cast-tv:20.0.0'
    implementation 'com.google.android.gms:play-services-cast:21.1.0'
}

同步项目以确认项目构建没有错误。

初始化

CastReceiverContext 是一个单例对象,用于协调所有 Cast 交互。您必须实现 ReceiverOptionsProvider 界面,以在系统初始化 CastReceiverContext 时提供 CastReceiverOptions

创建 CastReceiverOptionsProvider.kt 文件并将下列类添加到项目中:

package com.google.sample.cast.castconnect

import android.content.Context
import com.google.android.gms.cast.tv.ReceiverOptionsProvider
import com.google.android.gms.cast.tv.CastReceiverOptions

class CastReceiverOptionsProvider : ReceiverOptionsProvider {
    override fun getOptions(context: Context): CastReceiverOptions {
        return CastReceiverOptions.Builder(context)
                .setStatusText("Cast Connect Codelab")
                .build()
    }
}

然后在该应用的 AndroidManifest.xml 文件的 <application> 标记中指定接收器选项提供程序:

<application>
  ...
  <meta-data
    android:name="com.google.android.gms.cast.tv.RECEIVER_OPTIONS_PROVIDER_CLASS_NAME"
    android:value="com.google.sample.cast.castconnect.CastReceiverOptionsProvider" />
</application>

如需从您的 Cast 发送器连接您的 ATV 应用,请选择要启动的 activity。在此 Codelab 中,我们将在 Cast 会话启动时启动应用的 MainActivity。在 AndroidManifest.xml 文件中,将启动 intent 过滤器添加在 MainActivity 中。

<activity android:name=".MainActivity">
  ...
  <intent-filter>
    <action android:name="com.google.android.gms.cast.tv.action.LAUNCH" />
    <category android:name="android.intent.category.DEFAULT" />
  </intent-filter>
</activity>

Cast Receiver 上下文生命周期

您应该在应用启动时启动 CastReceiverContext,并在应用移至后台时停止 CastReceiverContext。我们建议您使用 androidx.lifecycle 库中的 LifecycleObserver 来管理调用 CastReceiverContext.start()CastReceiverContext.stop()

打开 MyApplication.kt 文件,通过在应用的 onCreate 方法中调用 initInstance() 来初始化投射上下文。在 AppLifeCycleObserver 类中,在应用恢复时,通过调用 start() 启动 CastReceiverContext;在应用暂停时,通过调用 stop() 停止 CastReceiverContext:

package com.google.sample.cast.castconnect

import com.google.android.gms.cast.tv.CastReceiverContext
...

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        CastReceiverContext.initInstance(this)
        ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver())
    }

    class AppLifecycleObserver : DefaultLifecycleObserver {
        override fun onResume(owner: LifecycleOwner) {
            Log.d(LOG_TAG, "onResume")
            CastReceiverContext.getInstance().start()
        }

        override fun onPause(owner: LifecycleOwner) {
            Log.d(LOG_TAG, "onPause")
            CastReceiverContext.getInstance().stop()
        }
    }
}

将 MediaSession 连接到 MediaManager

MediaManagerCastReceiverContext 单例的一个属性,用于管理媒体状态,处理加载 intent,将来自发送器的媒体命名空间消息转换为媒体命令,并将媒体状态发送回发送器。

创建 MediaSession 时,您还需要向 MediaManager 提供当前的 MediaSession 令牌,以便它知道要在何处发送命令以及检索媒体播放状态。在 PlaybackVideoFragment.kt 文件中,确保先初始化 MediaSession,然后再将令牌设置为 MediaManager

import com.google.android.gms.cast.tv.CastReceiverContext
import com.google.android.gms.cast.tv.media.MediaManager
...

class PlaybackVideoFragment : VideoSupportFragment() {
    private var castReceiverContext: CastReceiverContext? = null
    ...

    private fun initializePlayer() {
        if (mPlayer == null) {
            ...
            mMediaSession = MediaSessionCompat(getContext(), LOG_TAG)
            ...
            castReceiverContext = CastReceiverContext.getInstance()
            if (castReceiverContext != null) {
                val mediaManager: MediaManager = castReceiverContext!!.getMediaManager()
                mediaManager.setSessionCompatToken(mMediaSession!!.getSessionToken())
            }

        }
    }
}

在因播放挂起而释放 MediaSession 时,您应在 MediaManager 上设置 null 令牌:

private fun releasePlayer() {
    mMediaSession?.release()
    castReceiverContext?.mediaManager?.setSessionCompatToken(null)
    ...
}

运行示例应用

点击 Android Studio 的“Run”按钮,一个指向右侧的绿色三角形Run 按钮,在 ATV 设备上部署应用,关闭应用并返回 ATV 主屏幕。在您的发送器上,点击“投射”按钮 “投射”按钮图标,然后选择您的 ATV 设备。您会看到 ATV 应用已在 ATV 设备上启动,并且投射按钮状态已连接。

6. 加载媒体

通过 intent,使用您在开发者控制台中定义的软件包名称发送加载命令。您需要在 Android TV 应用中添加以下预定义的 intent 过滤器,以指定接收此 intent 的目标 activity。在 AndroidManifest.xml 文件中,将加载 intent 过滤器添加到 PlayerActivity

<activity android:name="com.google.sample.cast.castconnect.PlaybackActivity"
          android:launchMode="singleTask"
          android:exported="true">
  <intent-filter>
     <action android:name="com.google.android.gms.cast.tv.action.LOAD"/>
     <category android:name="android.intent.category.DEFAULT" />
  </intent-filter>
</activity>

在 Android TV 上处理加载请求

现在,activity 已配置为接收包含加载请求的 intent,我们需要对其进行处理。

当 activity 启动时,应用会调用名为 processIntent 的专有方法。此方法包含用于处理传入 intent 的逻辑。若要处理加载请求,我们需要修改此方法,并通过调用 MediaManager 实例的 onNewIntent 方法发送 intent 进行进一步处理。如果 MediaManager 检测到 intent 是加载请求,则会从此 intent 中提取 MediaLoadRequestData 对象并调用 MediaLoadCommandCallback.onLoad()。修改 PlaybackVideoFragment.kt 文件中的 processIntent 方法,以处理包含加载请求的 intent:

fun processIntent(intent: Intent?) {
    val mediaManager: MediaManager = CastReceiverContext.getInstance().getMediaManager()
    // Pass intent to Cast SDK
    if (mediaManager.onNewIntent(intent)) {
        return
    }

    // Clears all overrides in the modifier.
    mediaManager.getMediaStatusModifier().clear()

    // If the SDK doesn't recognize the intent, handle the intent with your own logic.
    ...
}

接下来,我们将扩展抽象类 MediaLoadCommandCallback,这将替换 MediaManager 调用的 onLoad() 方法。此方法用于接收加载请求的数据,并将其转换为 Movie 对象。转换后,影片将由本地播放器播放。然后,系统会利用 MediaLoadRequest 更新 MediaManager,并向连接的发送器广播 MediaStatus。在 PlaybackVideoFragment.kt 文件中创建一个名为 MyMediaLoadCommandCallback 的嵌套专用类:

import com.google.android.gms.cast.MediaLoadRequestData
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.MediaError
import com.google.android.gms.cast.tv.media.MediaException
import com.google.android.gms.cast.tv.media.MediaCommandCallback
import com.google.android.gms.cast.tv.media.QueueUpdateRequestData
import com.google.android.gms.cast.tv.media.MediaLoadCommandCallback
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import android.widget.Toast
...

private inner class MyMediaLoadCommandCallback :  MediaLoadCommandCallback() {
    override fun onLoad(
        senderId: String?, mediaLoadRequestData: MediaLoadRequestData): Task<MediaLoadRequestData> {
        Toast.makeText(activity, "onLoad()", Toast.LENGTH_SHORT).show()
        return if (mediaLoadRequestData == null) {
            // Throw MediaException to indicate load failure.
            Tasks.forException(MediaException(
                MediaError.Builder()
                    .setDetailedErrorCode(MediaError.DetailedErrorCode.LOAD_FAILED)
                    .setReason(MediaError.ERROR_REASON_INVALID_REQUEST)
                    .build()))
        } else Tasks.call {
            play(convertLoadRequestToMovie(mediaLoadRequestData)!!)
            // Update media metadata and state
            val mediaManager = castReceiverContext!!.mediaManager
            mediaManager.setDataFromLoad(mediaLoadRequestData)
            mediaLoadRequestData
        }
    }
}

private fun convertLoadRequestToMovie(mediaLoadRequestData: MediaLoadRequestData?): Movie? {
    if (mediaLoadRequestData == null) {
        return null
    }
    val mediaInfo: MediaInfo = mediaLoadRequestData.getMediaInfo() ?: return null
    var videoUrl: String = mediaInfo.getContentId()
    if (mediaInfo.getContentUrl() != null) {
        videoUrl = mediaInfo.getContentUrl()
    }
    val metadata: MediaMetadata = mediaInfo.getMetadata()
    val movie = Movie()
    movie.videoUrl = videoUrl
    movie.title = metadata?.getString(MediaMetadata.KEY_TITLE)
    movie.description = metadata?.getString(MediaMetadata.KEY_SUBTITLE)
    if(metadata?.hasImages() == true) {
        movie.cardImageUrl = metadata.images[0].url.toString()
    }
    return movie
}

已定义回调后,我们需要将它注册到 MediaManager 中。回调必须在调用 MediaManager.onNewIntent() 之前完成注册。在播放器初始化时添加 setMediaLoadCommandCallback

private fun initializePlayer() {
    if (mPlayer == null) {
        ...
        mMediaSession = MediaSessionCompat(getContext(), LOG_TAG)
        ...
        castReceiverContext = CastReceiverContext.getInstance()
        if (castReceiverContext != null) {
            val mediaManager: MediaManager = castReceiverContext.getMediaManager()
            mediaManager.setSessionCompatToken(mMediaSession.getSessionToken())
            mediaManager.setMediaLoadCommandCallback(MyMediaLoadCommandCallback())
        }
    }
}

运行示例应用

点击 Android Studio 的“Run”按钮,一个指向右侧的绿色三角形Run 按钮,在 ATV 设备上部署应用。在您的发送器上,点击“投射”按钮 “投射”按钮图标,然后选择您的 ATV 设备。ATV 应用将在 ATV 设备上启动。在移动设备上选择视频,该视频将在 ATV 上开始播放。检查您是否在显示播放控件的手机上收到了一条通知。尝试使用这些控件,例如使用暂停时,ATV 设备上的视频应被暂停。

7. 支持 Cast 控制命令

此时,当前应用可支持与媒体会话兼容的基本命令,例如播放、暂停和跳转。但是,有一些 Cast 控件命令无法在媒体会话中使用。您需要注册 MediaCommandCallback 才能支持那些 Cast 控件命令。

在播放器初始化时,使用 setMediaCommandCallbackMyMediaCommandCallback 添加到 MediaManager 实例:

private fun initializePlayer() {
    ...
    castReceiverContext = CastReceiverContext.getInstance()
    if (castReceiverContext != null) {
        val mediaManager = castReceiverContext!!.mediaManager
        ...
        mediaManager.setMediaCommandCallback(MyMediaCommandCallback())
    }
}

若要支持那些 Cast 控件命令,需创建 MyMediaCommandCallback 类以替换这些方法,例如 onQueueUpdate()

private inner class MyMediaCommandCallback : MediaCommandCallback() {
    override fun onQueueUpdate(
        senderId: String?,
        queueUpdateRequestData: QueueUpdateRequestData
    ): Task<Void> {
        Toast.makeText(getActivity(), "onQueueUpdate()", Toast.LENGTH_SHORT).show()
        // Queue Prev / Next
        if (queueUpdateRequestData.getJump() != null) {
            Toast.makeText(
                getActivity(),
                "onQueueUpdate(): Jump = " + queueUpdateRequestData.getJump(),
                Toast.LENGTH_SHORT
            ).show()
        }
        return super.onQueueUpdate(senderId, queueUpdateRequestData)
    }
}

8. 处理媒体状态

修改媒体状态

Cast Connect 从媒体会话中获取基本媒体状态。若要支持高级功能,您的 Android TV 应用可以通过 MediaStatusModifier 指定和替换其他状态属性。MediaStatusModifier 将始终按您在 CastReceiverContext 中设置的 MediaSession 进行操作。

例如,在触发 onLoad 回调时指定 setMediaCommandSupported

import com.google.android.gms.cast.MediaStatus
...
private class MyMediaLoadCommandCallback : MediaLoadCommandCallback() {
    fun onLoad(
        senderId: String?,
        mediaLoadRequestData: MediaLoadRequestData
    ): Task<MediaLoadRequestData> {
        Toast.makeText(getActivity(), "onLoad()", Toast.LENGTH_SHORT).show()
        ...
        return Tasks.call({
            play(convertLoadRequestToMovie(mediaLoadRequestData)!!)
            ...
            // Use MediaStatusModifier to provide additional information for Cast senders.
            mediaManager.getMediaStatusModifier()
                .setMediaCommandSupported(MediaStatus.COMMAND_QUEUE_NEXT, true)
                .setIsPlayingAd(false)
            mediaManager.broadcastMediaStatus()
            // Return the resolved MediaLoadRequestData to indicate load success.
            mediaLoadRequestData
        })
    }
}

在发送前拦截 MediaStatus

与网络接收器 SDK 的 MessageInterceptor 类似,您可以在 MediaManager 中指定 MediaStatusWriter,以便在将 MediaStatus 广播到连接的发送者之前对其进行进一步修改。

例如,您可以先在 MediaStatus 中设置自定义数据,然后再发送给手机发送器:

import com.google.android.gms.cast.tv.media.MediaManager.MediaStatusInterceptor
import com.google.android.gms.cast.tv.media.MediaStatusWriter
import org.json.JSONObject
import org.json.JSONException
...

private fun initializePlayer() {
    if (mPlayer == null) {
        ...
        if (castReceiverContext != null) {
            ...
            val mediaManager: MediaManager = castReceiverContext.getMediaManager()
            ...
            // Use MediaStatusInterceptor to process the MediaStatus before sending out.
            mediaManager.setMediaStatusInterceptor(
                MediaStatusInterceptor { mediaStatusWriter: MediaStatusWriter ->
                    try {
                        mediaStatusWriter.setCustomData(JSONObject("{myData: 'CustomData'}"))
                    } catch (e: JSONException) {
                        Log.e(LOG_TAG,e.message,e);
                    }
            })
        }
    }
}        

9. 恭喜

现在,您已了解如何使用 Cast Connect 库对 Android TV 应用启用投射。

如需了解详情,请参阅开发者指南:/cast/docs/android_tv_receiver