أنا أنظر وأستمع حيث أريد. دمج Chromecast في تطبيق Android


في الشارع ، أستمع غالبًا إلى الكتب الصوتية والبودكاست من هاتف ذكي. عندما أصل إلى المنزل ، أرغب في مواصلة الاستماع إليهم على Android TV أو Google Home. ولكن ليست كل التطبيقات تدعم Chromecast. وسيكون من المناسب.


وفقًا لإحصاءات Google على مدار السنوات الثلاث الماضية ، زاد عدد الأجهزة على Android TV 4 مرات ، وتجاوز عدد شركاء التصنيع بالفعل مائة: أجهزة تلفزيون ومكبرات صوت وصناديق استقبال ذكية "ذكية". كلهم يدعمون Chromecast. ولكن لا يزال هناك العديد من التطبيقات في السوق التي من الواضح أنها تفتقر إلى التكامل معها.


في هذه المقالة ، أود أن أشارك تجربتي في دمج Chromecast في تطبيق Android لتشغيل محتوى الوسائط.


كيف يعمل؟


إذا كانت هذه هي المرة الأولى التي تسمع فيها كلمة "Chromecast" ، سأحاول إخبارك باختصار. من حيث الاستخدام ، يبدو مثل هذا:


  1. يستمع المستخدم إلى الموسيقى أو يشاهد مقاطع الفيديو من خلال تطبيق أو موقع ويب.
  2. يظهر جهاز Chromecast على الشبكة المحلية.
  3. يجب أن يظهر زر المقابلة في واجهة اللاعب.
  4. من خلال النقر فوقه ، يحدد المستخدم الجهاز المطلوب من القائمة. يمكن أن يكون مشغل Nexus أو 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 Developers Console . يتطلب هذا حساب مطور Chromecast (يجب عدم الخلط بينه وبين حساب مطور Google Play). عند التسجيل ، يجب عليك دفع رسوم لمرة واحدة قدرها 5 دولارات. بعد نشر تطبيق ChromeCast ، تحتاج إلى الانتظار قليلاً.
في وحدة التحكم ، يمكنك تغيير مظهر مشغل 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 ثلاثة أنواع رئيسية من المحتوى:


  • الصوت
  • فيديو
  • صورة

اعتمادًا على إعدادات المشغل ، مثل محتوى الوسائط وجهاز الإرسال ، قد تختلف واجهة المشغل.


Castsession


لذلك ، المستخدم حدد الجهاز المطلوب ، افتتح 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() 

تذكر أن contentType هو نوع من المحتوى وفقًا لمواصفات MIME .
يمكنك أيضًا نقل إدراجات الإعلانات إلى 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 فيه. كان مشغل الوسائط مبنيًا على State Machine ، وكانت الفكرة الأولى هي إضافة حالة جديدة: "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. الآن ستصدر الأمر إلى قائمة مندوبي التشغيل ، وسيكون المفوض "الرصاص" قادرًا على معالجته. نظرًا لأن المفوض يقوم بتنفيذ جميع وظائف المشغل ، يمكن أيضًا توريثه من واجهة المشغل واستخدامه بشكل منفصل إذا لزم الأمر. عندها سيبدو المفوض المجرّد كما يلي:


 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 (). يمكن رؤية رمز عينة كاملة على جيثب .


هل هناك حياة بعد ChromeCast



بعد أن قمت بدمج دعم Chromecast في التطبيق ، كنت أكثر سعادة للعودة إلى المنزل والاستمتاع بالكتب الصوتية ليس فقط من خلال سماعات الرأس ، ولكن أيضًا من خلال Google Home. بالنسبة للهندسة المعمارية ، قد يختلف تنفيذ اللاعبين في التطبيقات المختلفة ، لذلك لن يكون هذا النهج مناسبًا في كل مكان. ولكن بالنسبة للهندسة المعمارية لدينا ، جاء الأمر. آمل أن يكون هذا المقال مفيدًا ، وفي المستقبل القريب سيكون هناك المزيد من التطبيقات التي يمكن أن تتكامل مع البيئة الرقمية!

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


All Articles