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

透過集合功能整理內容 你可以依據偏好儲存及分類內容。

1. 總覽

Google Cast 標誌

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

什麼是 Google Cast 和 Cast Connect?

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

「投放」工作階段中的所有狀態都儲存在接收端應用程式中。如果狀態更新 (例如載入新的媒體項目),系統會向所有寄件者播送媒體狀態。這些廣播包含 Cast 工作階段的目前狀態。寄件者應用程式會使用此媒體狀態,在其使用者介面中顯示播放資訊。

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

我們要建構的是什麼?

完成本程式碼研究室後,你即可使用投放傳送端應用程式,將影片投放到 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 裝置 (內建 Chromecast) 的序號。如需序號,請在 Android TV 上依序前往「Settings > Device Preferences > Chromecast 內建 >Google Play 序號」。請注意,這不同於實體裝置的序號,且必須從上述方法取得。

顯示 Android TV 螢幕的圖片,其中顯示「' 內建 Chromecast'」畫面、版本號碼和序號

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

安裝 Android 寄件者應用程式

為測試來自行動裝置的傳送要求,我們提供了名為 mobile-sender-0629.apk 檔案的簡易寄件者應用程式,來源為程式碼 ZIP 下載。我們將使用 ADB 安裝 APK。如果你已安裝其他版本的 Cast 影片,請先從裝置的所有設定檔解除安裝該版本,再繼續操作。

  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 &gt」(檔案 &gt);使用 Gradle 按鈕的 Android App Studio 同步處理專案「Sync Project with Gradle Files」(使用 Gradle 檔案同步處理專案)
  4. 在 Android TV 裝置上啟用開發人員選項和 USB 偵錯功能
  5. ADB 與 Android TV 裝置連線,該裝置應會顯示在 Android Studio 中。Android Studio 工具列上顯示的 Android TV 裝置圖片
  6. 按一下「Run」(執行) Android Studio 執行按鈕,綠色三角形指向右側按鈕,系統應該就會在幾秒鐘後顯示名為「Cast Connect Codelab」的 ATV 應用程式。

使用 ATV 應用程式播放 Cast Connect 內容

  1. 前往 Android TV 主畫面。
  2. 透過 Android 手機開啟投放影片寄件者應用程式。按一下「投放」按鈕 投放按鈕圖示,然後選取你的 ATV 裝置。
  3. TV 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 &gt」(檔案 &gt);Android Studio 和含有 Gradle 按鈕的同步處理專案「Sync Project with Gradle Files」(使用 Gradle 檔案同步處理專案)
  4. 選取 ATV 裝置,然後按一下 Android Studio 的「執行」按鈕,綠色三角形指向右側「Run」(執行) 按鈕來執行應用程式並探索使用者介面。顯示所選 Android TV 裝置的 Android Studio 工具列

一系列影片縮圖 (以方框特別標出) 的全螢幕影片內容,以及右上方的 ['Cast Connect']

應用程式設計

應用程式提供使用者的影片清單,方便他們瀏覽。使用者可以選取要在 Android TV 上播放的影片。這個應用程式包含兩個主要活動:MainActivityPlaybackActivity

MainActivity

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

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

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

播放活動

這項活動包含片段 (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 是協調所有 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() 中,應用程式重新啟用時為 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」(執行) 按鈕,即可在 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」(執行) 按鈕,即可在 ATV 裝置上部署應用程式。在寄件者頁面中,按一下「投放」按鈕 投放按鈕圖示,然後選取你的 ATV 裝置。ATV 應用程式會在 ATV 裝置上啟動。在行動裝置上選取影片,影片就會在 ATV 上播放。確認手機是否支援播放控制項。例如使用暫停功能,暫停播放 ATV 裝置上的影片。

7. 支援 Cast Control 指令

目前的應用程式支援與媒體工作階段相容的基本指令,例如播放、暫停和跳轉。但某些媒體控制指令不適用於媒體工作階段。您必須註冊 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