我看着我想要的地方听。 将Chromecast集成到Android应用中


在大街上,我经常听智能手机上的有声读物和播客。 到家后,我想继续在Android TV或Google Home上收听它们。 但并非所有应用程序都支持Chromecast。 这样会很方便。


根据Google过去3年的统计 ,Android TV上的设备数量增加了4倍,制造合作伙伴的数量已经超过了100:“智能”电视,扬声器,机顶盒。 它们都支持Chromecast。 但是市场上仍然有许多应用显然缺乏与之集成。


在本文中,我想分享一下我将Chromecast集成到用于播放媒体内容的Android应用程序中的经验。


如何运作


如果这是您第一次听到“ Chromecast”一词,我会尝试简单地告诉您。 在用法方面,它看起来像这样:


  1. 用户通过应用程序或网站收听音乐或观看视频。
  2. Chromecast设备出现在本地网络上。
  3. 相应的按钮应出现在播放器的界面中。
  4. 通过单击它,用户可以从列表中选择所需的设备。 它可以是Nexus Player,Android TV或智能扬声器。
  5. 此设备继续播放。


从技术上讲,会发生以下情况:


  1. Google服务通过广播监视本地网络上Chromecast设备的存在。
  2. 如果MediaRouter已连接到您的应用程序,则您将收到有关此事件。
  3. 当用户选择Cast设备并连接到它时,将打开一个新的媒体会话(CastSession)。
  4. 在创建的会话中,我们将传输内容以进行回放。
    听起来很简单。

整合性


Google拥有自己的Chromecast SDK ,但其文档覆盖率很低,并且其代码被混淆了。 因此,许多事情必须通过键入检查。 让我们整理一切。


初始化


首先,我们需要连接Cast应用程序框架和MediaRouter:


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

然后,Cast Framework应该获取应用程序标识符(稍后会详细介绍)以及支持的媒体内容的类型。 也就是说,如果我们的应用程序仅播放视频,则将无法投射到Google Home列,并且不在设备列表中。 为此,请创建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 } } 

并在清单中声明:


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

注册申请


为了使Chromecast与我们的应用一起使用,必须在Google Cast SDK开发者控制台中注册它。 这需要一个Chromecast开发者帐户(不要与Google Play开发者帐户混淆)。 注册时,您将需要支付5美元的一次性费用。 发布ChromeCast应用程序后,您需要稍等片刻。
在控制台中,您可以使用屏幕更改设备 Cast播放器的外观,并查看应用程序中的 Cast分析。


媒体路由器


MediaRouteFramework是一种允许您查找用户附近的所有远程播放设备的机制。 这不仅可以是Chromecast,也可以是使用第三方协议的远程显示器和扬声器。 但是让我们感兴趣的是Chromecast。



MediaRouteFramework具有一个可反映媒体滑板车状态的视图。 有两种连接方法:


1)通过菜单:


 <?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)通过布局:


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

从代码中,您只需要在CastButtonFactory中注册按钮。 则媒体滑板车的当前状态将被抛出:


 CastButtonFactory.setUpMediaRouteButton(applicationContext, view.mediaRouteButton) 

现在已经注册了应用程序并配置了MediaRouter,您可以连接到ChromeCast设备并打开与它们的会话。


投放媒体内容


ChromeCast支持三种主要的内容类型:


  • 音讯
  • 录影带
  • 相片

根据播放器的设置(例如媒体内容和投射设备),播放器的界面可能会有所不同。


演员表


因此,用户选择了所需的设备,CastFramework打开了一个新会话。 现在,我们的任务是对此做出响应,并将信息传递给设备进行播放。
要查找会话的当前状态并注册以更新此状态,请使用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) 

我们还可以了解当前是否有公开会议:


 val currentSession: CastSession? = sessionManager.currentCastSession 

我们有两个主要条件可以开始投射:


  1. 会话已经打开。
  2. 有铸造的内容。

在这两个事件的每一个中,我们都可以检查状态,如果一切正常,则可以开始投射。


铸件


现在我们有了要投射的东西和要投射的地方,我们可以继续进行最重要的事情。 除其他事项外,CastSession具有一个RemoteMediaClient对象,该对象负责媒体内容的播放状态。 我们将与他合作。


让我们创建MediaMetadata ,其中将存储有关作者,专辑等的信息,这与开始本地回放时传输到MediaSession的信息非常相似。


 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有很多参数,最好在文档中查看它们。 我很惊讶您可以不通过位图而是仅通过WebImage内的链接来添加图像。


MediaInfo对象携带有关内容元数据的信息,并将讨论媒体内容来自何处,它是什么类型,如何播放:


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

让我提醒您,根据MIME规范,contentType是一种内容类型。
您还可以将广告插入内容转移到MediaInfo:


  • setAdBreakClips-接受AdBreakClipInfo广告的列表, 其中包含指向内容,标题,时间以及跳过广告的时间的链接。
  • setAdBreaks-有关广告插入的布局和时间安排的信息。

在MediaLoadOptions中,我们描述了如何处理媒体流(速度,起始位置)。 该文档还说,您可以通过setCredentials传递请求标头以进行授权,但是我从Chromecast发出的请求中并未包含所请求的授权字段。


 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() 

一切准备就绪后,我们可以将所有数据提供给RemoteMediaClient,然后Chromecast将开始播放。 暂停本地播放很重要。


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

事件处理


视频开始播放,然后呢? 如果用户暂停电视怎么办? 要从Chromecast侧面了解事件,RemoteMediaClient具有回调:


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

要知道当前的进度也很简单:


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

与现有播放器集成的经验


我正在处理的应用程序已经有现成的媒体播放器。 目标是将Chromecast支持集成到其中。 媒体播放器基于状态机,首先想到的是添加一个新状态:“ CastingState”。 但是这个想法立即被拒绝了,因为每个播放器状态都反映了播放状态,并且什么用作ExoPlayer或ChromeCast的实现都无关紧要。
然后,提出了创建具有一定优先级并处理玩家“生命周期”的代表系统的想法。 所有代表都可以接收播放器状态事件:播放,暂停等。 -但只有首席代表才能播放媒体内容。



我们有这样的播放器界面:


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

它的内部将是一个具有许多状态的状态机:


  • 空-初始化之前的初始状态。
  • 准备-播放器启动媒体内容的播放。
  • 已准备-媒体已上传并可以播放。
  • 已暂停
  • 等待网络
  • 失误


以前,初始化期间的每个状态都会在ExoPlayer中发布命令。 现在,它将命令发布到“播放”委托人列表中,“ Lead”委托人将能够对其进行处理。 由于委托实现了播放器的所有功能,因此它也可以从播放器的界面继承,并在必要时单独使用。 然后,抽象委托将如下所示:


 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 } 

例如,我简化了接口。 实际上,还有更多事件。
可以有与复制源一样多的代表。 Chromecast代表可能看起来像这样:


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

在发出有关复制的命令之前,我们需要确定主要代表。 为此,将它们按优先级添加到播放器,并且它们每个都可以在readyForLeading()方法中给出其准备状态。 完整的示例代码可以在GitHub找到


ChromeCast之后有生命吗



将Chromecast支持集成到应用程序后,我很高兴回到家,不仅可以通过耳机,而且可以通过Google Home欣赏有声读物。 至于体系结构,播放器在不同应用程序中的实现可能会有所不同,因此这种方法并不适合所有地方。 但是对于我们的体系结构,它来了。 我希望本文对您有所帮助,并且在不久的将来,将会有更多可以与数字环境集成的应用程序!

Source: https://habr.com/ru/post/zh-CN442300/


All Articles