Saya melihat dan mendengarkan di mana saya inginkan. Mengintegrasikan Chromecast ke dalam Aplikasi Android


Di jalan, saya sering mendengarkan buku audio dan podcast dari telepon pintar. Ketika saya tiba di rumah, saya ingin terus mendengarkan mereka di TV Android atau Google Home. Tetapi tidak semua aplikasi mendukung Chromecast. Dan itu akan nyaman.


Menurut statistik Google selama 3 tahun terakhir, jumlah perangkat di Android TV telah meningkat 4 kali lipat, dan jumlah mitra manufaktur telah melebihi seratus: televisi, speaker, kotak set-top "pintar". Semuanya mendukung Chromecast. Tetapi masih ada banyak aplikasi di pasar yang jelas kurang integrasi dengan itu.


Pada artikel ini, saya ingin berbagi pengalaman mengintegrasikan Chromecast ke aplikasi Android untuk memutar konten media.


Bagaimana cara kerjanya


Jika ini pertama kalinya Anda mendengar kata "Chromecast," saya akan mencoba memberi tahu Anda secara singkat. Dalam hal penggunaan, tampilannya seperti ini:


  1. Pengguna mendengarkan musik atau menonton video melalui aplikasi atau situs web.
  2. Perangkat Chromecast muncul di jaringan lokal.
  3. Tombol yang sesuai akan muncul di antarmuka pemain.
  4. Dengan mengkliknya, pengguna memilih perangkat yang diinginkan dari daftar. Ini bisa menjadi Nexus Player, Android TV atau speaker pintar.
  5. Pemutaran lebih lanjut berlanjut dengan perangkat ini.


Secara teknis, sesuatu seperti yang berikut ini terjadi:


  1. Layanan Google memantau keberadaan perangkat Chromecast di jaringan lokal melalui Penyiaran.
  2. Jika MediaRouter terhubung ke aplikasi Anda, maka Anda akan menerima acara tentang ini.
  3. Saat pengguna memilih perangkat Cast dan menghubungkannya, sesi media baru (CastSession) terbuka.
  4. Sudah di sesi yang dibuat, kami akan mentransfer konten untuk diputar.
    Kedengarannya sangat sederhana.

Integrasi


Google memiliki Chromecast SDK sendiri, tetapi tidak tercakup dalam dokumentasi dan kodenya dikaburkan. Karena itu, banyak hal yang harus diperiksa dengan mengetik. Mari kita bereskan semuanya.


Inisialisasi


Pertama, kita perlu menghubungkan Cast Application Framework dan MediaRouter:


implementation "com.google.android.gms:play-services-cast-framework:16.1.0" implementation "androidx.mediarouter:mediarouter:1.0.0" 

Maka Cast Framework harus mendapatkan pengenal aplikasi (lebih lanjut tentang itu nanti), dan jenis konten media yang didukung. Artinya, jika aplikasi kita hanya memutar video, maka casting ke kolom Beranda Google tidak akan mungkin, dan itu tidak akan ada dalam daftar perangkat. Untuk melakukan ini, buat implementasi OptionsProvider:


 class CastOptionsProvider: OptionsProvider { override fun getCastOptions(context: Context): CastOptions { return CastOptions.Builder() .setReceiverApplicationId(BuildConfig.CHROMECAST_APP_ID) .build() } override fun getAdditionalSessionProviders(context: Context): MutableList<SessionProvider>? { return null } } 

Dan nyatakan dalam Manifest:


 <meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:value="your.app.package.CastOptionsProvider" /> 

Daftarkan aplikasi


Agar Chromecast berfungsi dengan aplikasi kami, itu harus didaftarkan di Google Cast SDK Developers Console . Ini memerlukan akun pengembang Chromecast (jangan bingung dengan akun pengembang Google Play). Saat mendaftar, Anda harus membayar biaya satu kali sebesar $ 5. Setelah menerbitkan Aplikasi ChromeCast, Anda perlu menunggu sedikit.
Di konsol, Anda dapat mengubah tampilan pemain Cast untuk perangkat dengan layar dan melihat analitik casting dalam aplikasi.


Router media


MediaRouteFramework adalah mekanisme yang memungkinkan Anda menemukan semua perangkat pemutaran jarak jauh di dekat pengguna. Ini tidak hanya Chromecast, tetapi juga display jarak jauh dan speaker menggunakan protokol pihak ketiga. Tetapi yang menarik bagi kami adalah Chromecast.



MediaRouteFramework memiliki Tampilan yang mencerminkan keadaan skuter media. Ada dua cara untuk menghubungkannya:


1) Melalui menu:


 <?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> ... <item android:id="@+id/menu_media_route" android:title="@string/cast" app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider" app:showAsAction="always"/> ... </menu> 

2) Melalui tata letak:


 <androidx.mediarouter.app.MediaRouteButton android:id="@+id/mediaRouteButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:mediaRouteTypes="user"/> 

Dan dari kode Anda hanya perlu mendaftarkan tombol di CastButtonFactory. maka keadaan saat ini dari skuter media akan dilemparkan ke dalamnya:


 CastButtonFactory.setUpMediaRouteButton(applicationContext, view.mediaRouteButton) 

Sekarang setelah aplikasi terdaftar dan MediaRouter dikonfigurasi, Anda dapat terhubung ke perangkat ChromeCast dan membuka sesi untuk mereka.


Casting Konten Media


ChromeCast mendukung tiga jenis konten utama:


  • Audio
  • Video
  • Foto

Bergantung pada pengaturan pemutar, seperti konten media dan perangkat transmisi, antarmuka pemain dapat bervariasi.


Castsession


Jadi, pengguna memilih perangkat yang diinginkan, CastFramework membuka sesi baru. Sekarang tugas kita adalah merespons ini dan meneruskan informasi ke perangkat untuk diputar.
Untuk mengetahui keadaan sesi saat ini dan mendaftar untuk memperbarui keadaan ini, gunakan objek SessionManager :


 private val mediaSessionListener = object : SessionManagerListener<CastSession> { override fun onSessionStarted(session: CastSession, sessionId: String) { currentSession = session //  ,      checkAndStartCasting() } override fun onSessionEnding(session: CastSession) { stopCasting() } override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { currentSession = session checkAndStartCasting() } override fun onSessionStartFailed(session: CastSession, p1: Int) { stopCasting() } override fun onSessionEnded(session: CastSession, p1: Int) { // do nothing } override fun onSessionResumeFailed(session: CastSession, p1: Int) { // do nothing } override fun onSessionSuspended(session: CastSession, p1: Int) { // do nothing } override fun onSessionStarting(session: CastSession) { // do nothing } override fun onSessionResuming(session: CastSession, sessionId: String) { // do nothing } } val sessionManager = CastContext.getSharedInstance(context).sessionManager sessionManager.addSessionManagerListener(mediaSessionListener, CastSession::class.java) 

Dan kita juga bisa mencari tahu apakah ada sesi terbuka saat ini:


 val currentSession: CastSession? = sessionManager.currentCastSession 

Kami memiliki dua kondisi utama di mana kami dapat mulai melakukan casting:


  1. Sesi sudah terbuka.
  2. Ada konten untuk casting.

Di masing-masing dari dua peristiwa ini, kita dapat memeriksa statusnya, dan jika semuanya sudah beres, maka mulai casting.


Casting


Sekarang kita memiliki apa yang harus dilemparkan dan ke mana harus dilemparkan, kita dapat beralih ke hal yang paling penting. Antara lain, CastSession memiliki objek RemoteMediaClient yang bertanggung jawab untuk keadaan pemutaran konten media. Kami akan bekerja dengannya.


Mari kita buat MediaMetadata , di mana informasi tentang penulis, album, dll akan disimpan. Ini sangat mirip dengan apa yang kita transfer ke MediaSession ketika kita memulai pemutaran lokal.


 val mediaMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK ).apply { putString(MediaMetadata.KEY_TITLE, “In C”) putString(MediaMetadata.KEY_ARTIST, “Terry Riley”) mediaContent?.metadata?.posterUrl?.let { poster -> addImage(WebImage(Uri.parse(“https://habrastorage.org/webt/wk/oi/pf/wkoipfkdyy2ctoa5evnd8vhxtem.png”))) } } 

MediaMetadata memiliki banyak parameter, dan lebih baik melihatnya di dokumentasi. Saya terkejut bahwa Anda dapat menambahkan gambar bukan melalui bitmap, tetapi hanya dengan tautan di dalam WebImage.


Objek MediaInfo membawa informasi tentang metadata konten dan akan berbicara tentang dari mana konten media berasal, apa jenisnya, bagaimana cara memainkannya:


 val mediaInfo = MediaInfo.Builder(“https://you-address.com/in_c.mp3”) .setContentType(“audio/mp3”) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setMetadata(mediaMetadata) .build() 

Biarkan saya mengingatkan Anda bahwa contentType adalah jenis konten sesuai dengan spesifikasi MIME .
Anda juga dapat mentransfer sisipan iklan ke MediaInfo:


  • setAdBreakClips - menerima daftar iklan AdBreakClipInfo dengan tautan ke konten, judul, waktu, dan waktu dilewati iklan.
  • setAdBreaks - informasi tentang tata letak dan waktu pemasangan iklan.

Dalam MediaLoadOptions kami menjelaskan bagaimana kami akan memproses aliran media (kecepatan, posisi awal). Dokumentasi juga mengatakan bahwa melalui setCredentials Anda dapat melewati tajuk permintaan untuk otorisasi, tetapi permintaan saya dari Chromecast tidak menyertakan bidang yang dinyatakan untuk otorisasi.


 val mediaLoadOptions = MediaLoadOptions.Builder() .setPlayPosition(position!!) .setAutoplay(true) .setPlaybackRate(playbackSpeed) .setCredentials(context.getString(R.string.bearer_token, authGateway.authState.accessToken!!)) .setCredentialsType(context.getString(R.string.authorization_header_key)) .build() 

Setelah semuanya siap, kami dapat memberikan semua data ke RemoteMediaClient, dan Chromecast akan mulai diputar. Penting untuk menjeda pemutaran lokal.


 val remoteMediaClient = currentSession!!.remoteMediaClient remoteMediaClient.load(mediaInfo, mediaLoadOptions) 

Penanganan acara


Video mulai diputar, lalu apa? Bagaimana jika pengguna menjeda TV? Untuk mempelajari tentang acara dari sisi Chromecast, RemoteMediaClient memiliki panggilan balik:


 private val castStatusCallback = object : RemoteMediaClient.Callback() { override fun onStatusUpdated() { // check and update current state } } remoteMediaClient.registerCallback(castStatusCallback) 

Untuk mengetahui kemajuan saat ini juga sederhana:


 val periodMills = 1000L remoteMediaClient.addProgressListener( RemoteMediaClient.ProgressListener { progressMills, durationMills -> // show progress in your UI }, periodMills ) 

Pengalaman berintegrasi dengan pemain yang ada


Aplikasi yang sedang saya kerjakan sudah memiliki media player yang sudah jadi. Tujuannya adalah untuk mengintegrasikan dukungan Chromecast ke dalamnya. Media player didasarkan pada State Machine, dan pemikiran pertama adalah menambahkan status baru: "CastingState". Tetapi ide ini langsung ditolak, karena setiap negara pemain mencerminkan keadaan pemutaran, dan tidak masalah apa yang berfungsi sebagai implementasi dari ExoPlayer atau ChromeCast.
Kemudian muncul ide untuk membuat sistem delegasi tertentu dengan memprioritaskan dan memproses "siklus hidup" pemain. Semua delegasi dapat menerima acara status pemain: Putar, Jeda, dll. - tetapi hanya delegasi pemimpin yang akan memutar konten media.



Kami memiliki sesuatu seperti antarmuka pemain ini:


 interface Player { val isPlaying: Boolean val isReleased: Boolean val duration: Long var positionInMillis: Long var speed: Float var volume: Float var loop: Boolean fun addListener(listener: PlayerCallback) fun removeListener(listener: PlayerCallback): Boolean fun getListeners(): MutableSet<PlayerCallback> fun prepare(mediaContent: MediaContent) fun play() fun pause() fun release() interface PlayerCallback { fun onPlaying(currentPosition: Long) fun onPaused(currentPosition: Long) fun onPreparing() fun onPrepared() fun onLoadingChanged(isLoading: Boolean) fun onDurationChanged(duration: Long) fun onSetSpeed(speed: Float) fun onSeekTo(fromTimeInMillis: Long, toTimeInMillis: Long) fun onWaitingForNetwork() fun onError(error: String?) fun onReleased() fun onPlayerProgress(currentPosition: Long) } } 

Di dalamnya akan menjadi Mesin Negara dengan begitu banyak negara:


  • Kosong - keadaan awal sebelum inisialisasi.
  • Mempersiapkan - pemain memulai pemutaran konten media.
  • Disiapkan - Media diunggah dan siap dimainkan.
  • Bermain
  • Dijeda
  • Menunggu jaringan
  • Kesalahan


Sebelumnya, setiap negara selama inisialisasi mengeluarkan perintah di ExoPlayer. Sekarang ia akan mengeluarkan perintah ke daftar delegasi Playing, dan delegasi "Lead" akan dapat memprosesnya. Karena delegasi mengimplementasikan semua fungsi pemain, ia juga dapat diwarisi dari antarmuka pemain dan digunakan secara terpisah jika perlu. Maka delegasi abstrak akan terlihat seperti ini:


 abstract class PlayingDelegate( protected val playerCallback: Player.PlayerCallback, var isLeading: Boolean = false ) : Player { fun setIsLeading(isLeading: Boolean, positionMills: Long, isPlaying: Boolean) { this.isLeading = isLeading if (isLeading) { onLeading(positionMills, isPlaying) } else { onDormant() } } final override fun addListener(listener: Player.PlayerCallback) { // do nothing } final override fun removeListener(listener: Player.PlayerCallback): Boolean { return false } final override fun getListeners(): MutableSet<Player.PlayerCallback> { return mutableSetOf() } /** *    */ open fun netwarkIsRestored() { // do nothing } /** *      */ abstract fun onLeading(positionMills: Long, isPlaying: Boolean) /** *      */ abstract fun onIdle() /** *     . *      , *       . */ abstract fun readyForLeading(): Boolean } 

Sebagai contoh, saya menyederhanakan antarmuka. Pada kenyataannya, ada sedikit lebih banyak peristiwa.
Mungkin ada banyak delegasi karena ada sumber reproduksi. Delegasi Chromecast mungkin terlihat seperti ini:


ChromeCastDelegate.kt
 class ChromeCastDelegate( private val context: Context, private val castCallback: ChromeCastListener, playerCallback: Player.PlayerCallback ) : PlayingDelegate(playerCallback) { companion object { private const val CONTENT_TYPE_VIDEO = "videos/mp4" private const val CONTENT_TYPE_AUDIO = "audio/mp3" private const val PROGRESS_DELAY_MILLS = 500L } interface ChromeCastListener { fun onCastStarted() fun onCastStopped() } private var sessionManager: SessionManager? = null private var currentSession: CastSession? = null private var mediaContent: MediaContent? = null private var currentPosition: Long = 0 private val mediaSessionListener = object : SessionManagerListener<CastSession> { override fun onSessionStarted(session: CastSession, sessionId: String) { currentSession = session castCallback.onCastStarted() } override fun onSessionEnding(session: CastSession) { currentPosition = session.remoteMediaClient?.approximateStreamPosition ?: currentPosition stopCasting() } override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { currentSession = session castCallback.onCastStarted() } override fun onSessionStartFailed(session: CastSession, p1: Int) { stopCasting() } override fun onSessionEnded(session: CastSession, p1: Int) { // do nothing } override fun onSessionResumeFailed(session: CastSession, p1: Int) { // do nothing } override fun onSessionSuspended(session: CastSession, p1: Int) { // do nothing } override fun onSessionStarting(session: CastSession) { // do nothing } override fun onSessionResuming(session: CastSession, sessionId: String) { // do nothing } } private val castStatusCallback = object : RemoteMediaClient.Callback() { override fun onStatusUpdated() { if (currentSession == null) return val playerState = currentSession!!.remoteMediaClient.playerState when (playerState) { MediaStatus.PLAYER_STATE_PLAYING -> playerCallback.onPlaying(positionInMillis) MediaStatus.PLAYER_STATE_PAUSED -> playerCallback.onPaused(positionInMillis) } } } private val progressListener = RemoteMediaClient.ProgressListener { progressMs, durationMs -> playerCallback.onPlayerProgress(progressMs) } // Playing delegate override val isReleased: Boolean = false override var loop: Boolean = false override val isPlaying: Boolean get() = currentSession?.remoteMediaClient?.isPlaying ?: false override val duration: Long get() = currentSession?.remoteMediaClient?.streamDuration ?: 0 override var positionInMillis: Long get() { currentPosition = currentSession?.remoteMediaClient?.approximateStreamPosition ?: currentPosition return currentPosition } set(value) { currentPosition = value checkAndStartCasting() } override var speed: Float = SpeedProvider.default() set(value) { field = value checkAndStartCasting() } override var volume: Float get() = currentSession?.volume?.toFloat() ?: 0F set(value) { currentSession?.volume = value.toDouble() } override fun prepare(mediaContent: MediaContent) { sessionManager = CastContext.getSharedInstance(context).sessionManager sessionManager?.addSessionManagerListener(mediaSessionListener, CastSession::class.java) currentSession = sessionManager?.currentCastSession this.mediaContent = mediaContent playerCallback.onPrepared() } override fun play() { if (isLeading) { currentSession?.remoteMediaClient?.play() } } override fun pause() { if (isLeading) { currentSession?.remoteMediaClient?.pause() } } override fun release() { stopCasting(true) } override fun onLeading(positionMills: Long, isPlaying: Boolean) { currentPosition = positionMills checkAndStartCasting() } override fun onIdle() { // TODO } override fun readyForLeading(): Boolean { return currentSession != null } // internal private fun checkAndStartCasting() { if (currentSession != null && mediaContent?.metadata != null && isLeading) { val mediaMetadata = MediaMetadata(getMetadataType(mediaContent!!.type)).apply { putString(MediaMetadata.KEY_TITLE, mediaContent?.metadata?.title.orEmpty()) putString(MediaMetadata.KEY_ARTIST, mediaContent?.metadata?.author.orEmpty()) mediaContent?.metadata?.posterUrl?.let { poster -> addImage(WebImage(Uri.parse(poster))) } } val mediaInfo = MediaInfo.Builder(mediaContent!!.contentUri.toString()) .setContentType(getContentType(mediaContent!!.type)) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setMetadata(mediaMetadata) .build() val mediaLoadOptions = MediaLoadOptions.Builder() .setPlayPosition(currentPosition) .setAutoplay(true) .setPlaybackRate(speed.toDouble()) .build() val remoteMediaClient = currentSession!!.remoteMediaClient remoteMediaClient.unregisterCallback(castStatusCallback) remoteMediaClient.load(mediaInfo, mediaLoadOptions) remoteMediaClient.registerCallback(castStatusCallback) remoteMediaClient.addProgressListener(progressListener, PROGRESS_DELAY_MILLS) } } private fun stopCasting(removeListener: Boolean = false) { if (removeListener) { sessionManager?.removeSessionManagerListener(mediaSessionListener, CastSession::class.java) } currentSession?.remoteMediaClient?.unregisterCallback(castStatusCallback) currentSession?.remoteMediaClient?.removeProgressListener(progressListener) currentSession?.remoteMediaClient?.stop() currentSession = null if (isLeading) { castCallback.onCastStopped() } } private fun getContentType(mediaType: MediaContent.Type) = when (mediaType) { MediaContent.Type.AUDIO -> CONTENT_TYPE_AUDIO MediaContent.Type.VIDEO -> CONTENT_TYPE_VIDEO } private fun getMetadataType(mediaType: MediaContent.Type) = when (mediaType) { MediaContent.Type.AUDIO -> MediaMetadata.MEDIA_TYPE_MUSIC_TRACK MediaContent.Type.VIDEO -> MediaMetadata.MEDIA_TYPE_MOVIE } } 

Sebelum memberikan perintah tentang reproduksi, kita perlu memutuskan delegasi terkemuka. Untuk melakukan ini, mereka ditambahkan dalam urutan prioritas ke pemain, dan masing-masing dari mereka dapat memberikan kesiapan dalam metode readyForLeading (). Kode sampel lengkap dapat dilihat di GitHub .


Apakah ada kehidupan setelah ChromeCast



Setelah saya mengintegrasikan dukungan Chromecast ke dalam aplikasi, saya lebih senang pulang dan menikmati buku audio tidak hanya melalui headphone, tetapi juga melalui Google Home. Sedangkan untuk arsitektur, implementasi pemain dalam aplikasi yang berbeda dapat bervariasi, sehingga pendekatan ini tidak akan sesuai di mana-mana. Tetapi untuk arsitektur kita, itu muncul. Saya harap artikel ini bermanfaat, dan dalam waktu dekat akan ada lebih banyak aplikasi yang dapat diintegrasikan dengan lingkungan digital!

Source: https://habr.com/ru/post/id442300/


All Articles