支援投放功能的 Android TV 應用程式

1. 總覽

Google Cast 標誌

本程式碼研究室將說明如何修改現有的 Android TV 應用程式,以支援從現有 Cast 發送端應用程式投放內容及通訊。

什麼是 Google Cast 和 Cast Connect?

Google Cast 可讓使用者將行動裝置上的內容投放到電視上。一般的 Google Cast 工作階段由兩個元件組成:傳送者接收端應用程式。傳送者應用程式 (例如 YouTube.com 等行動應用程式或網站) 會啟動及控制 Cast 接收器應用程式的播放作業。投放接收端應用程式是指在 Chromecast 和 Android TV 裝置上執行的 HTML 5 應用程式。

投放工作階段的所有狀態都儲存在接收器應用程式中。當狀態更新時 (例如,載入新媒體項目時),系統會將媒體狀態播送給所有傳送者。這些廣播包含投放工作階段的目前狀態。傳送者應用程式會使用此媒體狀態,在 UI 中顯示播放資訊。

Cast Connect 是以這個基礎架構之為基礎,您的 Android TV 應用程式可做為接收器。Cast Connect 程式庫可讓 Android TV 應用程式接收訊息和廣播媒體狀態,就像投放接收器應用程式一樣。

我們要建構什麼?

完成本程式碼研究室後,您就能使用 Cast 發送端應用程式將影片投放到 Android TV 應用程式。Android TV 應用程式也可以透過 Cast 通訊協定與傳送者應用程式通訊。

課程內容

  • 如何將 Cast Connect 程式庫新增至 ATV 範例應用程式。
  • 如何連結 Cast 傳送者並啟動 ATV 應用程式。
  • 如何透過 Cast 發送端應用程式啟動 ATV 應用程式中的媒體播放作業。
  • 如何將媒體狀態從 ATV 應用程式傳送至投放傳送端應用程式。

軟硬體需求

2. 取得程式碼範例

您可以將所有程式碼範例下載至電腦...

然後將下載的 ZIP 檔案解壓縮

3. 執行範例應用程式

首先,來看看完成的範例應用程式看起來會是什麼樣子。Android TV 應用程式使用 Leanback 使用者介面和基本影片播放器。使用者可從清單中選取影片,並在電視上播放。使用者也可以利用隨附的行動傳送應用程式,將影片投放到 Android TV 應用程式。

圖片中的一系列影片縮圖 (其中一個醒目顯示),並重疊在影片的全螢幕預覽畫面上,右上角顯示「Cast Connect」字樣

註冊開發人員裝置

如要啟用 Cast Connect 功能以便開發應用程式,您必須在 Cast 開發人員控制台中,註冊 Android TV 裝置內建 Chromecast 的序號。如要查看序號,請在 Android TV 上依序前往「設定」>「裝置偏好設定」>「內建 Chromecast」>「序號」。請注意,這組號碼與實體裝置的序號不同,您必須使用上述方法取得裝置序號。

圖片:Android TV 螢幕顯示「內建 Chromecast」畫面、版本編號和序號

基於安全考量,如未註冊,Cast Connect 將僅適用於從 Google Play 商店安裝的應用程式。開始註冊流程的 15 分鐘後,請重新啟動裝置。

安裝 Android 傳送者應用程式

為了測試從行動裝置送出的要求,我們在原始碼 ZIP 下載的原始碼中,提供了名為「投放影片」的簡易傳送者應用程式 mobile-sender-0629.apk。我們將利用 ADB 安裝 APK。如果您已安裝其他版本的投放影片,請先從裝置上的所有設定檔中解除安裝該版本,再繼續操作。

  1. 在 Android 手機上啟用開發人員選項和 USB 偵錯功能
  2. 插入 USB 資料傳輸線,將 Android 手機與開發電腦連接。
  3. 在 Android 手機上安裝「mobile-sender-0629.apk」。

執行 ADB 安裝指令以安裝 mobile-sender.apk 的終端機視窗圖片

  1. 您可以在 Android 手機上找到 Cast 影片發送程式。投放影片傳送者應用程式圖示

Android 手機螢幕上執行的「投放影片」應用程式端應用程式圖片

安裝 Android TV 應用程式

以下指示說明如何在 Android Studio 中開啟及執行已完成的範例應用程式:

  1. 在歡迎畫面中選取「Import Project」,或依序選取「File」>「New」>「Import Project...」選單選項。
  2. 從程式碼範例資料夾中選取 「資料夾」圖示app-done 目錄,然後按一下「OK」。
  3. 依序點選「File」>「Sync Project with Gradle Files」Android App Studio 的「Sync Project with Gradle」按鈕
  4. 在 Android TV 裝置上啟用開發人員選項和 USB 偵錯功能
  5. ADB 能與 Android TV 裝置連線,裝置應會顯示在 Android Studio 中。這張圖片顯示 Android TV 裝置顯示在 Android Studio 工具列上
  6. 按一下 Android Studio 執行按鈕,綠色三角形指向右邊「執行」按鈕,系統隨即會顯示名為「Cast Connect Codelab」的 ATV 應用程式。

開始透過 ATV 應用程式播放 Cast Connect

  1. 前往 Android TV 主畫面。
  2. 開啟 Android 手機中的 Cast 影片傳送者應用程式。按一下「投放」按鈕 投放按鈕圖示,然後選取你的 ATV 裝置。
  3. 系統會在 ATV 上啟動 Cast Connect Codelab ATV 應用程式,而傳送者中的「投放」按鈕會表示應用程式已連線至 投放按鈕圖示的色彩反轉
  4. 只要在 ATV 應用程式中選取影片,影片就會開始在 ATV 上播放。
  5. 現在手機上的傳送者應用程式底部會顯示迷你控制器。您可以使用播放/暫停按鈕控製播放。
  6. 從手機中選取一部影片並播放。影片會在 ATV 上開始播放,行動裝置的傳送者會顯示展開的控制器。
  7. 鎖定手機和手機解鎖時,螢幕鎖定畫面上應會顯示通知,可控制媒體播放或停止投放。

Android 手機螢幕的圖片,顯示正在播放影片的迷你播放器

4. 準備 start 專案

我們驗證了已完成應用程式的 Cast Connect 整合作業,因此需要在你下載的啟動應用程式中新增 Cast Connect 支援功能。您現在可以使用 Android Studio,以範例專案為基礎進行建構了:

  1. 在歡迎畫面中選取「Import Project」,或依序選取「File」>「New」>「Import Project...」選單選項。
  2. 從程式碼範例資料夾中選取 「資料夾」圖示app-start 目錄,然後按一下「OK」。
  3. 依序點選「File」>「Sync Project with Gradle Files」Android Studio 的「Sync Project with Gradle」按鈕
  4. 選取 ATV 裝置,然後按一下 Android Studio 的「Run」按鈕,綠色三角形指向右邊「Run」按鈕,執行應用程式並探索 UI。Android Studio 工具列顯示所選 Android TV 裝置

圖片中的一系列影片縮圖 (其中一個醒目顯示),並重疊在影片的全螢幕預覽畫面上,右上角顯示「Cast Connect」字樣

應用程式設計

應用程式會提供影片清單,讓使用者瀏覽。使用者可以選取要在 Android TV 上播放的影片。應用程式由 MainActivityPlaybackActivity 這兩個主要活動組成。

MainActivity

這個活動包含片段 (MainFragment)。影片清單及其相關中繼資料是在 MovieList 類別中設定,然後呼叫 setupMovies() 方法以建構 Movie 物件清單。

Movie 物件代表包含標題、說明、圖片縮圖和影片網址的影片實體。每個 Movie 物件都會繫結至 CardPresenter,呈現含有標題和 Studio 的影片縮圖,並傳遞至 ArrayObjectAdapter

選取項目後,系統會將對應的 Movie 物件傳遞至 PlaybackActivity

PlaybackActivity

這項活動包含片段 (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 是用於協調所有投放互動的單例模式物件。您必須實作 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 應用程式,請選取要啟動的活動。在本程式碼研究室中,我們會在投放工作階段開始時啟動應用程式的 MainActivity。在 AndroidManifest.xml 檔案的 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>

投放接收端情境生命週期

您應在應用程式啟動時啟動 CastReceiverContext,並在應用程式移至背景時停止 CastReceiverContext。建議您使用 androidx.lifecycle 程式庫中的 LifecycleObserver,以便管理呼叫 CastReceiverContext.start()CastReceiverContext.stop()

開啟 MyApplication.kt 檔案,在應用程式的 onCreate 方法中呼叫 initInstance(),藉此初始化投放結構定義。在 AppLifeCycleObserver 類別中,應用程式恢復時 start() CastReceiverContext,並在應用程式暫停時 stop()

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 單例模式的屬性,可管理媒體狀態、處理載入意圖、將寄件者傳送的媒體命名空間訊息轉譯為媒體指令,並將媒體狀態傳回給傳送者。

建立 MediaSession 時,您也必須將目前的 MediaSession 權杖提供給 MediaManager,使其知道要在哪裡傳送指令並擷取媒體播放狀態。在 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 設定空值權杖:

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

執行範例應用程式

按一下 Android Studio 的「Run」按鈕,綠色三角形指向右邊「Run」按鈕,在 ATV 裝置上部署應用程式,然後關閉應用程式並返回 ATV 主畫面。在傳送者中,按一下「投放」按鈕 投放按鈕圖示 並選取你的 ATV 裝置。您會看到 ATV 應用程式已在 ATV 裝置上啟動,且「Cast」按鈕狀態已連線。

6. 載入媒體

載入指令會透過您在開發人員控制台中定義的套件名稱的意圖傳送。您需要在 Android TV 應用程式中新增下列預先定義的意圖篩選器,指定要接收此意圖的目標活動。在 AndroidManifest.xml 檔案中,將載入意圖篩選器新增至 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 的載入要求

現在活動已設為接收包含載入要求的意圖,我們需要處理此意圖。

活動啟動時,應用程式會呼叫名為 processIntent 的不公開方法。這個方法包含處理傳入意圖的邏輯。為了處理載入要求,我們會修改這個方法,並呼叫 MediaManager 例項的 onNewIntent 方法,傳送要進一步處理的意圖。如果 MediaManager 偵測到意圖是載入要求,則會從意圖中擷取 MediaLoadRequestData 物件並叫用 MediaLoadCommandCallback.onLoad()。修改 PlaybackVideoFragment.kt 檔案中的 processIntent 方法以處理包含載入要求的意圖:

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 物件。轉換後,本機播放器會播放電影。接著,MediaManager 會透過 MediaLoadRequest 更新,並將 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. 支援投放控制指令

目前的應用程式支援與媒體工作階段相容的基本指令,例如播放、暫停和跳轉等。不過,媒體工作階段不支援某些投放控制指令。你必須註冊 MediaCommandCallback,才能支援這些投放控制指令。

在玩家初始化時,使用 setMediaCommandCallbackMyMediaCommandCallback 新增至 MediaManager 例項:

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

建立 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