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

1. 總覽

Google Cast 標誌

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

什麼是 Google Cast 和 Cast Connect?

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

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

Cast Connect 會在這個基礎架構之上運作,而 Android TV 應用程式會充當接收端。Cast Connect 程式庫可讓 Android TV 應用程式接收訊息和廣播媒體狀態,就像是 Cast 接收器應用程式一樣。

我們要建構什麼內容?

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

課程內容

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

軟硬體需求

2. 取得程式碼範例

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

並解壓縮下載的 ZIP 檔案。

3. 執行範例應用程式

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

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

註冊開發人員裝置

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

Android TV 螢幕畫面圖片,顯示「Google Cast」畫面、版本號碼和序號

為確保安全,未註冊的 Cast Connect 僅支援從 Google Play 商店安裝的應用程式。開始註冊程序後 15 分鐘,請重新啟動裝置。

安裝 Android 傳送端應用程式

為了測試透過行動裝置傳送的要求,我們在原始碼 ZIP 檔案中提供名為 Cast Videos 的簡易傳送端應用程式,檔案格式為 mobile-sender-0629.apk。我們將使用 ADB 安裝 APK。如果你已安裝其他版本的 Cast 影片,請先從裝置上的所有設定檔中解除安裝該版本,再繼續操作。

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

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

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

在 Android 手機螢幕上執行的 Cast 影片傳送端應用程式圖片

安裝 Android TV 應用程式

下列操作說明說明如何在 Android Studio 中開啟及執行完成的範例應用程式:

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

搭配 ATV 應用程式使用 Cast Connect

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

圖片:Android 手機螢幕的部分畫面,其中顯示正在播放影片的迷你播放器

4. 準備 start 專案

完成應用程式的 Cast Connect 整合後,我們需要在您下載的啟動應用程式中新增 Cast Connect 支援功能。您現在可以使用 Android Studio 在入門專案上進行建構:

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

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

應用程式設計

應用程式會提供影片清單供使用者瀏覽。使用者可以選取要播放的影片。這個應用程式包含兩個主要活動:MainActivityPlaybackActivity

MainActivity

這個活動包含一個 Fragment (MainFragment)。影片清單及其相關中繼資料會在 MovieList 類別中設定,並呼叫 setupMovies() 方法來建立 Movie 物件的清單。

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

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

PlaybackActivity

這個活動包含一個 Fragment (PlaybackVideoFragment),可代管含有 ExoPlayerVideoView、一些媒體控制項,以及顯示所選影片說明的文字區域,並允許使用者在 Android TV 上播放影片。使用者可以使用遙控器播放/暫停影片,或跳轉至特定時間點。

Cast Connect 先決條件

Cast Connect 使用新版 Google Play 服務,因此 ATV 應用程式必須更新至使用 AndroidX 命名空間。

如要在 Android TV 應用程式中支援 Cast Connect,您必須透過媒體工作階段建立並支援事件。Cast Connect 程式庫會根據媒體工作階段的狀態產生媒體狀態。Cast Connect 程式庫也會使用媒體工作階段,在收到寄件者傳送的特定訊息 (例如暫停) 時發出訊號。

5. 設定投放支援功能

依附元件

更新應用程式 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 應用程式,請選取要啟動的活動。在本程式碼研究室中,我們會在 Cast 工作階段啟動時,啟動應用程式的 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 Receiver 情境生命週期

您應在應用程式啟動時啟動 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,讓 MediaManager 知道要將指令傳送到哪裡,以及擷取媒體播放狀態。在 PlaybackVideoFragment.kt 檔案中,請先將符記設為 MediaManager,再初始化 MediaSession

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

執行範例應用程式

按一下「Run」按鈕,即可在 ATV 裝置上部署應用程式、關閉應用程式並返回 ATV 主畫面。Android Studio 的「Run」按鈕,即右側的綠色三角形在傳送端,按一下「投放」按鈕 投放按鈕圖示,然後選取 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 物件。轉換完成後,本機播放器就會播放電影。接著,系統會使用 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())
        }
    }
}

執行範例應用程式

按一下「Run」按鈕,即可在 ATV 裝置上部署應用程式。Android Studio 的「Run」按鈕,即右側的綠色三角形在傳送端,按一下「投放」按鈕 投放按鈕圖示,然後選取 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 中設定自訂資料:

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