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

1. 總覽

Google Cast 標誌

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

什麼是 Google Cast 和 Cast Connect?

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

投放工作階段中的幾乎所有狀態都儲存在接收端應用程式上。狀態更新時,例如載入新的媒體項目時,系統會向所有傳送者播送媒體狀態。這些廣播訊息包括投放工作階段的目前狀態。傳送者應用程式會使用這個媒體狀態,在使用者介面中顯示播放資訊。

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

我們要建構哪些項目?

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

課程內容

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

軟硬體需求

2. 取得範例程式碼

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

並將下載的 ZIP 檔案解壓縮

3. 執行範例應用程式

首先,我們來看看完成的範例應用程式的外觀。Android TV 應用程式使用 Leanback UI 和基本影片播放器。使用者可以從清單中選取影片,之後影片就會在電視上播放。使用者也可以透過隨附的行動裝置傳送應用程式,將影片投放到 Android TV 應用程式。

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

註冊開發人員裝置

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

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

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

安裝 Android 傳送者應用程式

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

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

終端機視窗的圖片,用於執行 adb 安裝指令安裝 mobile-sender.apk

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

「投放影片」傳送者應用程式在 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 執行按鈕,這是指向右側的綠色三角形「Run」(執行) 按鈕,您應該會在幾秒後看到名為「Cast Connect Codelab」的 ATV 應用程式。

歡迎來瞭解如何透過 ATV 應用程式使用 Cast Connect 功能

  1. 前往 Android TV 主畫面。
  2. 開啟 Android 手機上的「投放影片傳送者應用程式」。按一下「投放」按鈕 「投放」按鈕圖示,然後選取你的 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」(執行) 按鈕,即可執行應用程式並探索使用者介面。Android Studio 工具列顯示所選 Android TV 裝置

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

應用程式設計

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

MainActivity

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

Movie 物件代表影片實體,內含標題、說明、圖片喜歡和影片網址。每個 Movie 物件都會繫結至 CardPresenter,以顯示含有標題和工作室的影片縮圖,並傳遞至 ArrayObjectAdapter

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

PlaybackActivity

這項活動包含 Fragment (PlaybackVideoFragment),這個 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 應用程式,請選取要啟動的活動。在本程式碼研究室中,我們會在投放工作階段啟動時啟動應用程式的 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>

Cast 接收器結構定義生命週期

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

開啟 MyApplication.kt 檔案,並在應用程式的 onCreate 方法中呼叫 initInstance(),以初始化投放結構定義。在AppLifeCycleObserver類別start()中,當應用程式繼續執行時,CastReceiverContextstop()應用程式中暫停時:

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 時,您還需要向 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 上設定空值符記:

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

讓我們執行範例應用程式

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

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

與 Web 接收器 SDK 的 MessageInterceptor 類似,您可以在 MediaManager 中指定 MediaStatusWriter,對 MediaStatus 進行額外修改,再將 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 功能。

詳情請參閱開發人員指南:/cast/docs/android_tv_receiver