1 /*
<lambda>null2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.media.controls.ui.controller
18 
19 import android.app.PendingIntent
20 import android.content.Context
21 import android.content.Intent
22 import android.content.res.ColorStateList
23 import android.content.res.Configuration
24 import android.database.ContentObserver
25 import android.os.UserHandle
26 import android.provider.Settings
27 import android.provider.Settings.ACTION_MEDIA_CONTROLS_SETTINGS
28 import android.util.Log
29 import android.util.MathUtils
30 import android.view.LayoutInflater
31 import android.view.View
32 import android.view.ViewGroup
33 import android.view.animation.PathInterpolator
34 import android.widget.LinearLayout
35 import androidx.annotation.VisibleForTesting
36 import androidx.lifecycle.Lifecycle
37 import androidx.lifecycle.repeatOnLifecycle
38 import androidx.recyclerview.widget.DiffUtil
39 import com.android.app.tracing.traceSection
40 import com.android.internal.logging.InstanceId
41 import com.android.keyguard.KeyguardUpdateMonitor
42 import com.android.keyguard.KeyguardUpdateMonitorCallback
43 import com.android.systemui.Dumpable
44 import com.android.systemui.dagger.SysUISingleton
45 import com.android.systemui.dagger.qualifiers.Background
46 import com.android.systemui.dagger.qualifiers.Main
47 import com.android.systemui.dump.DumpManager
48 import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
49 import com.android.systemui.keyguard.shared.model.Edge
50 import com.android.systemui.keyguard.shared.model.KeyguardState
51 import com.android.systemui.keyguard.shared.model.KeyguardState.GONE
52 import com.android.systemui.keyguard.shared.model.KeyguardState.LOCKSCREEN
53 import com.android.systemui.keyguard.shared.model.TransitionState
54 import com.android.systemui.lifecycle.repeatWhenAttached
55 import com.android.systemui.media.controls.domain.pipeline.MediaDataManager
56 import com.android.systemui.media.controls.shared.model.MediaData
57 import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
58 import com.android.systemui.media.controls.ui.binder.MediaControlViewBinder
59 import com.android.systemui.media.controls.ui.binder.MediaRecommendationsViewBinder
60 import com.android.systemui.media.controls.ui.controller.MediaControlPanel.SMARTSPACE_CARD_DISMISS_EVENT
61 import com.android.systemui.media.controls.ui.util.MediaViewModelCallback
62 import com.android.systemui.media.controls.ui.util.MediaViewModelListUpdateCallback
63 import com.android.systemui.media.controls.ui.view.MediaCarouselScrollHandler
64 import com.android.systemui.media.controls.ui.view.MediaHostState
65 import com.android.systemui.media.controls.ui.view.MediaScrollView
66 import com.android.systemui.media.controls.ui.view.MediaViewHolder
67 import com.android.systemui.media.controls.ui.view.RecommendationViewHolder
68 import com.android.systemui.media.controls.ui.viewmodel.MediaCarouselViewModel
69 import com.android.systemui.media.controls.ui.viewmodel.MediaCommonViewModel
70 import com.android.systemui.media.controls.util.MediaFlags
71 import com.android.systemui.media.controls.util.MediaUiEventLogger
72 import com.android.systemui.media.controls.util.SmallHash
73 import com.android.systemui.plugins.ActivityStarter
74 import com.android.systemui.plugins.FalsingManager
75 import com.android.systemui.qs.PageIndicator
76 import com.android.systemui.res.R
77 import com.android.systemui.scene.domain.interactor.SceneInteractor
78 import com.android.systemui.scene.shared.flag.SceneContainerFlag
79 import com.android.systemui.scene.shared.model.Scenes
80 import com.android.systemui.shared.system.SysUiStatsLog
81 import com.android.systemui.shared.system.SysUiStatsLog.SMARTSPACE_CARD_REPORTED
82 import com.android.systemui.shared.system.SysUiStatsLog.SMART_SPACE_CARD_REPORTED__CARD_TYPE__UNKNOWN_CARD
83 import com.android.systemui.shared.system.SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__DREAM_OVERLAY as SSPACE_CARD_REPORTED__DREAM_OVERLAY
84 import com.android.systemui.shared.system.SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN as SSPACE_CARD_REPORTED__LOCKSCREEN
85 import com.android.systemui.shared.system.SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE
86 import com.android.systemui.statusbar.notification.collection.provider.OnReorderingAllowedListener
87 import com.android.systemui.statusbar.notification.collection.provider.VisualStabilityProvider
88 import com.android.systemui.statusbar.policy.ConfigurationController
89 import com.android.systemui.util.Utils
90 import com.android.systemui.util.animation.UniqueObjectHostView
91 import com.android.systemui.util.animation.requiresRemeasuring
92 import com.android.systemui.util.concurrency.DelayableExecutor
93 import com.android.systemui.util.settings.GlobalSettings
94 import com.android.systemui.util.settings.SecureSettings
95 import com.android.systemui.util.settings.SettingsProxyExt.observerFlow
96 import com.android.systemui.util.time.SystemClock
97 import java.io.PrintWriter
98 import java.util.Locale
99 import java.util.TreeMap
100 import java.util.concurrent.Executor
101 import javax.inject.Inject
102 import javax.inject.Provider
103 import kotlinx.coroutines.CoroutineDispatcher
104 import kotlinx.coroutines.CoroutineScope
105 import kotlinx.coroutines.Job
106 import kotlinx.coroutines.flow.collectLatest
107 import kotlinx.coroutines.flow.distinctUntilChanged
108 import kotlinx.coroutines.flow.filter
109 import kotlinx.coroutines.flow.map
110 import kotlinx.coroutines.flow.onStart
111 import kotlinx.coroutines.launch
112 import kotlinx.coroutines.withContext
113 
114 private const val TAG = "MediaCarouselController"
115 private val settingsIntent = Intent().setAction(ACTION_MEDIA_CONTROLS_SETTINGS)
116 private val DEBUG = Log.isLoggable(TAG, Log.DEBUG)
117 
118 /**
119  * Class that is responsible for keeping the view carousel up to date. This also handles changes in
120  * state and applies them to the media carousel like the expansion.
121  */
122 @SysUISingleton
123 class MediaCarouselController
124 @Inject
125 constructor(
126     private val context: Context,
127     private val mediaControlPanelFactory: Provider<MediaControlPanel>,
128     private val visualStabilityProvider: VisualStabilityProvider,
129     private val mediaHostStatesManager: MediaHostStatesManager,
130     private val activityStarter: ActivityStarter,
131     private val systemClock: SystemClock,
132     @Main private val mainDispatcher: CoroutineDispatcher,
133     @Main executor: DelayableExecutor,
134     @Background private val bgExecutor: Executor,
135     @Background private val backgroundDispatcher: CoroutineDispatcher,
136     private val mediaManager: MediaDataManager,
137     configurationController: ConfigurationController,
138     private val falsingManager: FalsingManager,
139     dumpManager: DumpManager,
140     private val logger: MediaUiEventLogger,
141     private val debugLogger: MediaCarouselControllerLogger,
142     private val mediaFlags: MediaFlags,
143     private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
144     private val keyguardTransitionInteractor: KeyguardTransitionInteractor,
145     private val globalSettings: GlobalSettings,
146     private val secureSettings: SecureSettings,
147     private val mediaCarouselViewModel: MediaCarouselViewModel,
148     private val mediaViewControllerFactory: Provider<MediaViewController>,
149     private val sceneInteractor: SceneInteractor,
150 ) : Dumpable {
151     /** The current width of the carousel */
152     var currentCarouselWidth: Int = 0
153         private set
154 
155     /** The current height of the carousel */
156     private var currentCarouselHeight: Int = 0
157 
158     /** Are we currently showing only active players */
159     private var currentlyShowingOnlyActive: Boolean = false
160 
161     /** Is the player currently visible (at the end of the transformation */
162     private var playersVisible: Boolean = false
163 
164     /**
165      * The desired location where we'll be at the end of the transformation. Usually this matches
166      * the end location, except when we're still waiting on a state update call.
167      */
168     @MediaLocation private var desiredLocation: Int = -1
169 
170     /**
171      * The ending location of the view where it ends when all animations and transitions have
172      * finished
173      */
174     @MediaLocation @VisibleForTesting var currentEndLocation: Int = -1
175 
176     /**
177      * The ending location of the view where it ends when all animations and transitions have
178      * finished
179      */
180     @MediaLocation private var currentStartLocation: Int = -1
181 
182     /** The progress of the transition or 1.0 if there is no transition happening */
183     private var currentTransitionProgress: Float = 1.0f
184 
185     /** The measured width of the carousel */
186     private var carouselMeasureWidth: Int = 0
187 
188     /** The measured height of the carousel */
189     private var carouselMeasureHeight: Int = 0
190     private var desiredHostState: MediaHostState? = null
191     @VisibleForTesting var mediaCarousel: MediaScrollView
192     val mediaCarouselScrollHandler: MediaCarouselScrollHandler
193     val mediaFrame: ViewGroup
194 
195     @VisibleForTesting
196     lateinit var settingsButton: View
197         private set
198 
199     private val mediaContent: ViewGroup
200     @VisibleForTesting var pageIndicator: PageIndicator
201     private var needsReordering: Boolean = false
202     private var isUserInitiatedRemovalQueued: Boolean = false
203     private var keysNeedRemoval = mutableSetOf<String>()
204     var shouldScrollToKey: Boolean = false
205     private var isRtl: Boolean = false
206         set(value) {
207             if (value != field) {
208                 field = value
209                 mediaFrame.layoutDirection =
210                     if (value) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR
211                 mediaCarouselScrollHandler.scrollToStart()
212             }
213         }
214 
215     private var carouselLocale: Locale? = null
216 
217     private val animationScaleObserver: ContentObserver =
218         object : ContentObserver(executor, 0) {
219             override fun onChange(selfChange: Boolean) {
220                 if (!mediaFlags.isSceneContainerEnabled()) {
221                     MediaPlayerData.players().forEach { it.updateAnimatorDurationScale() }
222                 } else {
223                     controllerByViewModel.values.forEach { it.updateAnimatorDurationScale() }
224                 }
225             }
226         }
227 
228     private var allowMediaPlayerOnLockScreen = false
229 
230     /** Whether the media card currently has the "expanded" layout */
231     @VisibleForTesting
232     var currentlyExpanded = true
233         set(value) {
234             if (field != value) {
235                 field = value
236                 updateSeekbarListening(mediaCarouselScrollHandler.visibleToUser)
237             }
238         }
239 
240     companion object {
241         val TRANSFORM_BEZIER = PathInterpolator(0.68F, 0F, 0F, 1F)
242 
243         fun calculateAlpha(
244             squishinessFraction: Float,
245             startPosition: Float,
246             endPosition: Float
247         ): Float {
248             val transformFraction =
249                 MathUtils.constrain(
250                     (squishinessFraction - startPosition) / (endPosition - startPosition),
251                     0F,
252                     1F
253                 )
254             return TRANSFORM_BEZIER.getInterpolation(transformFraction)
255         }
256     }
257 
258     private val configListener =
259         object : ConfigurationController.ConfigurationListener {
260 
261             override fun onDensityOrFontScaleChanged() {
262                 // System font changes should only happen when UMO is offscreen or a flicker may
263                 // occur
264                 updatePlayers(recreateMedia = true)
265                 inflateSettingsButton()
266             }
267 
268             override fun onThemeChanged() {
269                 updatePlayers(recreateMedia = false)
270                 inflateSettingsButton()
271             }
272 
273             override fun onConfigChanged(newConfig: Configuration?) {
274                 if (newConfig == null) return
275                 isRtl = newConfig.layoutDirection == View.LAYOUT_DIRECTION_RTL
276             }
277 
278             override fun onUiModeChanged() {
279                 updatePlayers(recreateMedia = false)
280                 inflateSettingsButton()
281             }
282 
283             override fun onLocaleListChanged() {
284                 // Update players only if system primary language changes.
285                 if (carouselLocale != context.resources.configuration.locales.get(0)) {
286                     carouselLocale = context.resources.configuration.locales.get(0)
287                     updatePlayers(recreateMedia = true)
288                     inflateSettingsButton()
289                 }
290             }
291         }
292 
293     private val keyguardUpdateMonitorCallback =
294         object : KeyguardUpdateMonitorCallback() {
295             override fun onStrongAuthStateChanged(userId: Int) {
296                 if (keyguardUpdateMonitor.isUserInLockdown(userId)) {
297                     debugLogger.logCarouselHidden()
298                     hideMediaCarousel()
299                 } else if (keyguardUpdateMonitor.isUserUnlocked(userId)) {
300                     debugLogger.logCarouselVisible()
301                     showMediaCarousel()
302                 }
303             }
304         }
305 
306     /**
307      * Update MediaCarouselScrollHandler.visibleToUser to reflect media card container visibility.
308      * It will be called when the container is out of view.
309      */
310     lateinit var updateUserVisibility: () -> Unit
311     var updateHostVisibility: () -> Unit = {}
312         set(value) {
313             field = value
314             mediaCarouselViewModel.updateHostVisibility = value
315         }
316 
317     private val isReorderingAllowed: Boolean
318         get() = visualStabilityProvider.isReorderingAllowed
319 
320     /** Size provided by the scene framework container */
321     private var widthInSceneContainerPx = 0
322     private var heightInSceneContainerPx = 0
323 
324     private val controllerByViewModel = mutableMapOf<MediaCommonViewModel, MediaViewController>()
325     private val commonViewModels = mutableListOf<MediaCommonViewModel>()
326 
327     init {
328         dumpManager.registerDumpable(TAG, this)
329         mediaFrame = inflateMediaCarousel()
330         mediaCarousel = mediaFrame.requireViewById(R.id.media_carousel_scroller)
331         pageIndicator = mediaFrame.requireViewById(R.id.media_page_indicator)
332         mediaCarouselScrollHandler =
333             MediaCarouselScrollHandler(
334                 mediaCarousel,
335                 pageIndicator,
336                 executor,
337                 this::onSwipeToDismiss,
338                 this::updatePageIndicatorLocation,
339                 this::updateSeekbarListening,
340                 this::closeGuts,
341                 falsingManager,
342                 this::logSmartspaceImpression,
343                 logger
344             )
345         carouselLocale = context.resources.configuration.locales.get(0)
346         isRtl = context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
347         inflateSettingsButton()
348         mediaContent = mediaCarousel.requireViewById(R.id.media_carousel)
349         configurationController.addCallback(configListener)
350         if (!mediaFlags.isSceneContainerEnabled()) {
351             setUpListeners()
352         } else {
353             val visualStabilityCallback = OnReorderingAllowedListener {
354                 mediaCarouselViewModel.onReorderingAllowed()
355 
356                 // Update user visibility so that no extra impression will be logged when
357                 // activeMediaIndex resets to 0
358                 if (this::updateUserVisibility.isInitialized) {
359                     updateUserVisibility()
360                 }
361 
362                 // Let's reset our scroll position
363                 mediaCarouselScrollHandler.scrollToStart()
364             }
365             visualStabilityProvider.addPersistentReorderingAllowedListener(visualStabilityCallback)
366         }
367         mediaFrame.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
368             // The pageIndicator is not laid out yet when we get the current state update,
369             // Lets make sure we have the right dimensions
370             updatePageIndicatorLocation()
371         }
372         mediaHostStatesManager.addCallback(
373             object : MediaHostStatesManager.Callback {
374                 override fun onHostStateChanged(
375                     @MediaLocation location: Int,
376                     mediaHostState: MediaHostState
377                 ) {
378                     updateUserVisibility()
379                     if (location == desiredLocation) {
380                         onDesiredLocationChanged(desiredLocation, mediaHostState, animate = false)
381                     }
382                 }
383             }
384         )
385         keyguardUpdateMonitor.registerCallback(keyguardUpdateMonitorCallback)
386         mediaCarousel.repeatWhenAttached {
387             repeatOnLifecycle(Lifecycle.State.STARTED) {
388                 listenForAnyStateToGoneKeyguardTransition(this)
389                 listenForAnyStateToLockscreenTransition(this)
390                 listenForLockscreenSettingChanges(this)
391 
392                 if (!mediaFlags.isSceneContainerEnabled()) return@repeatOnLifecycle
393                 listenForMediaItemsChanges(this)
394             }
395         }
396 
397         // Notifies all active players about animation scale changes.
398         bgExecutor.execute {
399             globalSettings.registerContentObserverSync(
400                     Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE),
401                     animationScaleObserver
402             )
403         }
404     }
405 
406     private fun setUpListeners() {
407         val visualStabilityCallback = OnReorderingAllowedListener {
408             if (needsReordering) {
409                 needsReordering = false
410                 reorderAllPlayers(previousVisiblePlayerKey = null)
411             }
412 
413             keysNeedRemoval.forEach {
414                 removePlayer(it, userInitiated = isUserInitiatedRemovalQueued)
415             }
416             if (keysNeedRemoval.size > 0) {
417                 // Carousel visibility may need to be updated after late removals
418                 updateHostVisibility()
419             }
420             keysNeedRemoval.clear()
421             isUserInitiatedRemovalQueued = false
422 
423             // Update user visibility so that no extra impression will be logged when
424             // activeMediaIndex resets to 0
425             if (this::updateUserVisibility.isInitialized) {
426                 updateUserVisibility()
427             }
428 
429             // Let's reset our scroll position
430             mediaCarouselScrollHandler.scrollToStart()
431         }
432         visualStabilityProvider.addPersistentReorderingAllowedListener(visualStabilityCallback)
433         mediaManager.addListener(
434             object : MediaDataManager.Listener {
435                 override fun onMediaDataLoaded(
436                     key: String,
437                     oldKey: String?,
438                     data: MediaData,
439                     immediately: Boolean,
440                     receivedSmartspaceCardLatency: Int,
441                     isSsReactivated: Boolean
442                 ) {
443                     debugLogger.logMediaLoaded(key, data.active)
444                     if (addOrUpdatePlayer(key, oldKey, data, isSsReactivated)) {
445                         // Log card received if a new resumable media card is added
446                         MediaPlayerData.getMediaPlayer(key)?.let {
447                             logSmartspaceCardReported(
448                                 759, // SMARTSPACE_CARD_RECEIVED
449                                 it.mSmartspaceId,
450                                 it.mUid,
451                                 surfaces =
452                                     intArrayOf(
453                                         SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
454                                         SSPACE_CARD_REPORTED__LOCKSCREEN,
455                                         SSPACE_CARD_REPORTED__DREAM_OVERLAY,
456                                     ),
457                                 rank = MediaPlayerData.getMediaPlayerIndex(key)
458                             )
459                         }
460                         if (
461                             mediaCarouselScrollHandler.visibleToUser &&
462                                 mediaCarouselScrollHandler.visibleMediaIndex ==
463                                     MediaPlayerData.getMediaPlayerIndex(key)
464                         ) {
465                             logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded)
466                         }
467                     } else if (receivedSmartspaceCardLatency != 0) {
468                         // Log resume card received if resumable media card is reactivated and
469                         // resume card is ranked first
470                         MediaPlayerData.players().forEachIndexed { index, it ->
471                             if (it.recommendationViewHolder == null) {
472                                 it.mSmartspaceId =
473                                     SmallHash.hash(
474                                         it.mUid + systemClock.currentTimeMillis().toInt()
475                                     )
476                                 it.mIsImpressed = false
477 
478                                 logSmartspaceCardReported(
479                                     759, // SMARTSPACE_CARD_RECEIVED
480                                     it.mSmartspaceId,
481                                     it.mUid,
482                                     surfaces =
483                                         intArrayOf(
484                                             SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
485                                             SSPACE_CARD_REPORTED__LOCKSCREEN,
486                                             SSPACE_CARD_REPORTED__DREAM_OVERLAY,
487                                         ),
488                                     rank = index,
489                                     receivedLatencyMillis = receivedSmartspaceCardLatency
490                                 )
491                             }
492                         }
493                         // If media container area already visible to the user, log impression for
494                         // reactivated card.
495                         if (
496                             mediaCarouselScrollHandler.visibleToUser &&
497                                 !mediaCarouselScrollHandler.qsExpanded
498                         ) {
499                             logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded)
500                         }
501                     }
502 
503                     val canRemove = data.isPlaying?.let { !it } ?: data.isClearable && !data.active
504                     if (canRemove && !Utils.useMediaResumption(context)) {
505                         // This media control is both paused and timed out, and the resumption
506                         // setting is off - let's remove it
507                         if (isReorderingAllowed) {
508                             onMediaDataRemoved(key, userInitiated = MediaPlayerData.isSwipedAway)
509                         } else {
510                             isUserInitiatedRemovalQueued = MediaPlayerData.isSwipedAway
511                             keysNeedRemoval.add(key)
512                         }
513                     } else {
514                         keysNeedRemoval.remove(key)
515                     }
516                     MediaPlayerData.isSwipedAway = false
517                 }
518 
519                 override fun onSmartspaceMediaDataLoaded(
520                     key: String,
521                     data: SmartspaceMediaData,
522                     shouldPrioritize: Boolean
523                 ) {
524                     debugLogger.logRecommendationLoaded(key, data.isActive)
525                     // Log the case where the hidden media carousel with the existed inactive resume
526                     // media is shown by the Smartspace signal.
527                     if (data.isActive) {
528                         val hasActivatedExistedResumeMedia =
529                             !mediaManager.hasActiveMedia() &&
530                                 mediaManager.hasAnyMedia() &&
531                                 shouldPrioritize
532                         if (hasActivatedExistedResumeMedia) {
533                             // Log resume card received if resumable media card is reactivated and
534                             // recommendation card is valid and ranked first
535                             MediaPlayerData.players().forEachIndexed { index, it ->
536                                 if (it.recommendationViewHolder == null) {
537                                     it.mSmartspaceId =
538                                         SmallHash.hash(
539                                             it.mUid + systemClock.currentTimeMillis().toInt()
540                                         )
541                                     it.mIsImpressed = false
542 
543                                     logSmartspaceCardReported(
544                                         759, // SMARTSPACE_CARD_RECEIVED
545                                         it.mSmartspaceId,
546                                         it.mUid,
547                                         surfaces =
548                                             intArrayOf(
549                                                 SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
550                                                 SSPACE_CARD_REPORTED__LOCKSCREEN,
551                                                 SSPACE_CARD_REPORTED__DREAM_OVERLAY,
552                                             ),
553                                         rank = index,
554                                         receivedLatencyMillis =
555                                             (systemClock.currentTimeMillis() -
556                                                     data.headphoneConnectionTimeMillis)
557                                                 .toInt()
558                                     )
559                                 }
560                             }
561                         }
562                         addSmartspaceMediaRecommendations(key, data, shouldPrioritize)
563                         MediaPlayerData.getMediaPlayer(key)?.let {
564                             logSmartspaceCardReported(
565                                 759, // SMARTSPACE_CARD_RECEIVED
566                                 it.mSmartspaceId,
567                                 it.mUid,
568                                 surfaces =
569                                     intArrayOf(
570                                         SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
571                                         SSPACE_CARD_REPORTED__LOCKSCREEN,
572                                         SSPACE_CARD_REPORTED__DREAM_OVERLAY,
573                                     ),
574                                 rank = MediaPlayerData.getMediaPlayerIndex(key),
575                                 receivedLatencyMillis =
576                                     (systemClock.currentTimeMillis() -
577                                             data.headphoneConnectionTimeMillis)
578                                         .toInt()
579                             )
580                         }
581                         if (
582                             mediaCarouselScrollHandler.visibleToUser &&
583                                 mediaCarouselScrollHandler.visibleMediaIndex ==
584                                     MediaPlayerData.getMediaPlayerIndex(key)
585                         ) {
586                             logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded)
587                         }
588                     } else {
589                         if (!mediaFlags.isPersistentSsCardEnabled()) {
590                             // Handle update to inactive as a removal
591                             onSmartspaceMediaDataRemoved(data.targetId, immediately = true)
592                         } else {
593                             addSmartspaceMediaRecommendations(key, data, shouldPrioritize)
594                         }
595                     }
596                     MediaPlayerData.isSwipedAway = false
597                 }
598 
599                 override fun onMediaDataRemoved(key: String, userInitiated: Boolean) {
600                     debugLogger.logMediaRemoved(key, userInitiated)
601                     removePlayer(key, userInitiated = userInitiated)
602                 }
603 
604                 override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
605                     debugLogger.logRecommendationRemoved(key, immediately)
606                     if (immediately || isReorderingAllowed) {
607                         removePlayer(key)
608                         if (!immediately) {
609                             // Although it wasn't requested, we were able to process the removal
610                             // immediately since reordering is allowed. So, notify hosts to update
611                             updateHostVisibility()
612                         }
613                     } else {
614                         keysNeedRemoval.add(key)
615                     }
616                 }
617             }
618         )
619     }
620 
621     private fun inflateSettingsButton() {
622         val settings =
623             LayoutInflater.from(context)
624                 .inflate(R.layout.media_carousel_settings_button, mediaFrame, false) as View
625         if (this::settingsButton.isInitialized) {
626             mediaFrame.removeView(settingsButton)
627         }
628         settingsButton = settings
629         mediaFrame.addView(settingsButton)
630         mediaCarouselScrollHandler.onSettingsButtonUpdated(settings)
631         settingsButton.setOnClickListener {
632             logger.logCarouselSettings()
633             activityStarter.startActivity(
634                 settingsIntent,
635                 /* dismissShade= */ true,
636             )
637         }
638     }
639 
640     private fun inflateMediaCarousel(): ViewGroup {
641         val mediaCarousel =
642             LayoutInflater.from(context)
643                 .inflate(R.layout.media_carousel, UniqueObjectHostView(context), false) as ViewGroup
644         // Because this is inflated when not attached to the true view hierarchy, it resolves some
645         // potential issues to force that the layout direction is defined by the locale
646         // (rather than inherited from the parent, which would resolve to LTR when unattached).
647         mediaCarousel.layoutDirection = View.LAYOUT_DIRECTION_LOCALE
648         return mediaCarousel
649     }
650 
651     private fun hideMediaCarousel() {
652         mediaCarousel.visibility = View.GONE
653     }
654 
655     private fun showMediaCarousel() {
656         mediaCarousel.visibility = View.VISIBLE
657     }
658 
659     @VisibleForTesting
660     internal fun listenForAnyStateToGoneKeyguardTransition(scope: CoroutineScope): Job {
661         return scope.launch {
662             if (SceneContainerFlag.isEnabled) {
663                     sceneInteractor.transitionState.filter { it.isIdle(Scenes.Gone) }
664                 } else {
665                     keyguardTransitionInteractor.transition(Edge.create(to = GONE)).filter {
666                         it.transitionState == TransitionState.FINISHED
667                     }
668                 }
669                 .collect {
670                     showMediaCarousel()
671                     updateHostVisibility()
672                 }
673         }
674     }
675 
676     @VisibleForTesting
677     internal fun listenForAnyStateToLockscreenTransition(scope: CoroutineScope): Job {
678         return scope.launch {
679             keyguardTransitionInteractor
680                 .transition(Edge.create(to = LOCKSCREEN))
681                 .filter { it.transitionState == TransitionState.FINISHED }
682                 .collect {
683                     if (!allowMediaPlayerOnLockScreen) {
684                         updateHostVisibility()
685                     }
686                 }
687         }
688     }
689 
690     @VisibleForTesting
691     internal fun listenForLockscreenSettingChanges(scope: CoroutineScope): Job {
692         return scope.launch {
693             secureSettings
694                 .observerFlow(UserHandle.USER_ALL, Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN)
695                 // query to get initial value
696                 .onStart { emit(Unit) }
697                 .map { getMediaLockScreenSetting() }
698                 .distinctUntilChanged()
699                 .collectLatest {
700                     allowMediaPlayerOnLockScreen = it
701                     updateHostVisibility()
702                 }
703         }
704     }
705 
706     private fun listenForMediaItemsChanges(scope: CoroutineScope): Job {
707         return scope.launch {
708             mediaCarouselViewModel.mediaItems.collectLatest {
709                 val diffUtilCallback = MediaViewModelCallback(commonViewModels, it)
710                 val listUpdateCallback =
711                     MediaViewModelListUpdateCallback(
712                         old = commonViewModels,
713                         new = it,
714                         onAdded = this@MediaCarouselController::onAdded,
715                         onUpdated = this@MediaCarouselController::onUpdated,
716                         onRemoved = this@MediaCarouselController::onRemoved,
717                         onMoved = this@MediaCarouselController::onMoved,
718                     )
719                 DiffUtil.calculateDiff(diffUtilCallback).dispatchUpdatesTo(listUpdateCallback)
720                 setNewViewModelsList(it)
721             }
722         }
723     }
724 
725     private fun onAdded(commonViewModel: MediaCommonViewModel, position: Int) {
726         val viewController = mediaViewControllerFactory.get()
727         viewController.sizeChangedListener = this::updateCarouselDimensions
728         val lp =
729             LinearLayout.LayoutParams(
730                 ViewGroup.LayoutParams.MATCH_PARENT,
731                 ViewGroup.LayoutParams.WRAP_CONTENT
732             )
733         when (commonViewModel) {
734             is MediaCommonViewModel.MediaControl -> {
735                 val viewHolder = MediaViewHolder.create(LayoutInflater.from(context), mediaContent)
736                 if (mediaFlags.isSceneContainerEnabled()) {
737                     viewController.widthInSceneContainerPx = widthInSceneContainerPx
738                     viewController.heightInSceneContainerPx = heightInSceneContainerPx
739                 }
740                 viewController.attachPlayer(viewHolder)
741                 viewController.mediaViewHolder.player.layoutParams = lp
742                 MediaControlViewBinder.bind(
743                     viewHolder,
744                     commonViewModel.controlViewModel,
745                     viewController,
746                     falsingManager,
747                     backgroundDispatcher,
748                     mainDispatcher,
749                     mediaFlags
750                 )
751                 mediaContent.addView(viewHolder.player, position)
752             }
753             is MediaCommonViewModel.MediaRecommendations -> {
754                 val viewHolder =
755                     RecommendationViewHolder.create(LayoutInflater.from(context), mediaContent)
756                 viewController.attachRecommendations(viewHolder)
757                 viewController.recommendationViewHolder.recommendations.layoutParams = lp
758                 MediaRecommendationsViewBinder.bind(
759                     viewHolder,
760                     commonViewModel.recsViewModel,
761                     viewController,
762                     falsingManager,
763                 )
764                 mediaContent.addView(viewHolder.recommendations, position)
765             }
766         }
767         viewController.setListening(mediaCarouselScrollHandler.visibleToUser && currentlyExpanded)
768         controllerByViewModel[commonViewModel] = viewController
769         updateViewControllerToState(viewController, noAnimation = true)
770         updatePageIndicator()
771         if (
772             commonViewModel is MediaCommonViewModel.MediaControl && commonViewModel.isMediaFromRec
773         ) {
774             mediaCarouselScrollHandler.scrollToPlayer(
775                 mediaCarouselScrollHandler.visibleMediaIndex,
776                 destIndex = 0
777             )
778         }
779         mediaCarouselScrollHandler.onPlayersChanged()
780         mediaFrame.requiresRemeasuring = true
781         commonViewModel.onAdded(commonViewModel)
782     }
783 
784     private fun onUpdated(commonViewModel: MediaCommonViewModel) {
785         commonViewModel.onUpdated(commonViewModel)
786         updatePageIndicator()
787         mediaCarouselScrollHandler.onPlayersChanged()
788     }
789 
790     private fun onRemoved(commonViewModel: MediaCommonViewModel) {
791         controllerByViewModel.remove(commonViewModel)?.let {
792             when (commonViewModel) {
793                 is MediaCommonViewModel.MediaControl -> {
794                     mediaCarouselScrollHandler.onPrePlayerRemoved(it.mediaViewHolder.player)
795                     mediaContent.removeView(it.mediaViewHolder.player)
796                 }
797                 is MediaCommonViewModel.MediaRecommendations -> {
798                     mediaContent.removeView(it.recommendationViewHolder.recommendations)
799                 }
800             }
801             it.onDestroy()
802             mediaCarouselScrollHandler.onPlayersChanged()
803             updatePageIndicator()
804             commonViewModel.onRemoved(true)
805         }
806     }
807 
808     private fun onMoved(commonViewModel: MediaCommonViewModel, from: Int, to: Int) {
809         controllerByViewModel[commonViewModel]?.let {
810             mediaContent.removeViewAt(from)
811             when (commonViewModel) {
812                 is MediaCommonViewModel.MediaControl -> {
813                     mediaContent.addView(it.mediaViewHolder.player, to)
814                 }
815                 is MediaCommonViewModel.MediaRecommendations -> {
816                     mediaContent.addView(it.recommendationViewHolder.recommendations, to)
817                 }
818             }
819         }
820         updatePageIndicator()
821         mediaCarouselScrollHandler.onPlayersChanged()
822     }
823 
824     private fun setNewViewModelsList(viewModels: List<MediaCommonViewModel>) {
825         commonViewModels.clear()
826         commonViewModels.addAll(viewModels)
827 
828         // Ensure we only show the needed UMOs in media carousel.
829         val viewSet = viewModels.toHashSet()
830         controllerByViewModel.filter { !viewSet.contains(it.key) }.forEach { onRemoved(it.key) }
831     }
832 
833     private suspend fun getMediaLockScreenSetting(): Boolean {
834         return withContext(backgroundDispatcher) {
835             secureSettings.getBoolForUser(
836                 Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN,
837                 true,
838                 UserHandle.USER_CURRENT
839             )
840         }
841     }
842 
843     fun setSceneContainerSize(width: Int, height: Int) {
844         if (width == widthInSceneContainerPx && height == heightInSceneContainerPx) {
845             return
846         }
847         if (width <= 0 || height <= 0) {
848             // reject as invalid
849             return
850         }
851         widthInSceneContainerPx = width
852         heightInSceneContainerPx = height
853         mediaCarouselScrollHandler.playerWidthPlusPadding =
854             width + context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
855         updatePlayers(recreateMedia = true)
856     }
857 
858     /** Return true if the carousel should be hidden because lockscreen is currently visible */
859     fun isLockedAndHidden(): Boolean {
860         val keyguardState = keyguardTransitionInteractor.getFinishedState()
861         return !allowMediaPlayerOnLockScreen &&
862             KeyguardState.lockscreenVisibleInState(keyguardState)
863     }
864 
865     private fun reorderAllPlayers(
866         previousVisiblePlayerKey: MediaPlayerData.MediaSortKey?,
867         key: String? = null
868     ) {
869         mediaContent.removeAllViews()
870         for (mediaPlayer in MediaPlayerData.players()) {
871             mediaPlayer.mediaViewHolder?.let { mediaContent.addView(it.player) }
872                 ?: mediaPlayer.recommendationViewHolder?.let {
873                     mediaContent.addView(it.recommendations)
874                 }
875         }
876         mediaCarouselScrollHandler.onPlayersChanged()
877         MediaPlayerData.updateVisibleMediaPlayers()
878         // Automatically scroll to the active player if needed
879         if (shouldScrollToKey) {
880             shouldScrollToKey = false
881             val mediaIndex = key?.let { MediaPlayerData.getMediaPlayerIndex(it) } ?: -1
882             if (mediaIndex != -1) {
883                 previousVisiblePlayerKey?.let {
884                     val previousVisibleIndex =
885                         MediaPlayerData.playerKeys().indexOfFirst { key -> it == key }
886                     mediaCarouselScrollHandler.scrollToPlayer(previousVisibleIndex, mediaIndex)
887                 }
888                     ?: mediaCarouselScrollHandler.scrollToPlayer(destIndex = mediaIndex)
889             }
890         } else if (isRtl && mediaContent.childCount > 0) {
891             // In RTL, Scroll to the first player as it is the rightmost player in media carousel.
892             mediaCarouselScrollHandler.scrollToPlayer(destIndex = 0)
893         }
894         // Check postcondition: mediaContent should have the same number of children as there
895         // are
896         // elements in mediaPlayers.
897         if (MediaPlayerData.players().size != mediaContent.childCount) {
898             Log.e(
899                 TAG,
900                 "Size of players list and number of views in carousel are out of sync. " +
901                     "Players size is ${MediaPlayerData.players().size}. " +
902                     "View count is ${mediaContent.childCount}."
903             )
904         }
905     }
906 
907     // Returns true if new player is added
908     private fun addOrUpdatePlayer(
909         key: String,
910         oldKey: String?,
911         data: MediaData,
912         isSsReactivated: Boolean
913     ): Boolean =
914         traceSection("MediaCarouselController#addOrUpdatePlayer") {
915             MediaPlayerData.moveIfExists(oldKey, key)
916             val existingPlayer = MediaPlayerData.getMediaPlayer(key)
917             val curVisibleMediaKey =
918                 MediaPlayerData.visiblePlayerKeys()
919                     .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
920             if (existingPlayer == null) {
921                 val newPlayer = mediaControlPanelFactory.get()
922                 if (mediaFlags.isSceneContainerEnabled()) {
923                     newPlayer.mediaViewController.widthInSceneContainerPx = widthInSceneContainerPx
924                     newPlayer.mediaViewController.heightInSceneContainerPx =
925                         heightInSceneContainerPx
926                 }
927                 newPlayer.attachPlayer(
928                     MediaViewHolder.create(LayoutInflater.from(context), mediaContent)
929                 )
930                 newPlayer.mediaViewController.sizeChangedListener = this::updateCarouselDimensions
931                 val lp =
932                     LinearLayout.LayoutParams(
933                         ViewGroup.LayoutParams.MATCH_PARENT,
934                         ViewGroup.LayoutParams.WRAP_CONTENT
935                     )
936                 newPlayer.mediaViewHolder?.player?.setLayoutParams(lp)
937                 newPlayer.bindPlayer(data, key)
938                 newPlayer.setListening(
939                     mediaCarouselScrollHandler.visibleToUser && currentlyExpanded
940                 )
941                 MediaPlayerData.addMediaPlayer(
942                     key,
943                     data,
944                     newPlayer,
945                     systemClock,
946                     isSsReactivated,
947                     debugLogger
948                 )
949                 updateViewControllerToState(newPlayer.mediaViewController, noAnimation = true)
950                 // Media data added from a recommendation card should starts playing.
951                 if (
952                     (shouldScrollToKey && data.isPlaying == true) ||
953                         (!shouldScrollToKey && data.active)
954                 ) {
955                     reorderAllPlayers(curVisibleMediaKey, key)
956                 } else {
957                     needsReordering = true
958                 }
959             } else {
960                 existingPlayer.bindPlayer(data, key)
961                 MediaPlayerData.addMediaPlayer(
962                     key,
963                     data,
964                     existingPlayer,
965                     systemClock,
966                     isSsReactivated,
967                     debugLogger
968                 )
969                 val packageName = MediaPlayerData.smartspaceMediaData?.packageName ?: String()
970                 // In case of recommendations hits.
971                 // Check the playing status of media player and the package name.
972                 // To make sure we scroll to the right app's media player.
973                 if (
974                     isReorderingAllowed ||
975                         shouldScrollToKey &&
976                             data.isPlaying == true &&
977                             packageName == data.packageName
978                 ) {
979                     reorderAllPlayers(curVisibleMediaKey, key)
980                 } else {
981                     needsReordering = true
982                 }
983             }
984             updatePageIndicator()
985             mediaCarouselScrollHandler.onPlayersChanged()
986             mediaFrame.requiresRemeasuring = true
987             return existingPlayer == null
988         }
989 
990     private fun addSmartspaceMediaRecommendations(
991         key: String,
992         data: SmartspaceMediaData,
993         shouldPrioritize: Boolean
994     ) =
995         traceSection("MediaCarouselController#addSmartspaceMediaRecommendations") {
996             if (DEBUG) Log.d(TAG, "Updating smartspace target in carousel")
997             MediaPlayerData.getMediaPlayer(key)?.let {
998                 if (mediaFlags.isPersistentSsCardEnabled()) {
999                     // The card exists, but could have changed active state, so update for sorting
1000                     MediaPlayerData.addMediaRecommendation(
1001                         key,
1002                         data,
1003                         it,
1004                         shouldPrioritize,
1005                         systemClock,
1006                         debugLogger,
1007                         update = true,
1008                     )
1009                 }
1010                 Log.w(TAG, "Skip adding smartspace target in carousel")
1011                 return
1012             }
1013 
1014             val existingSmartspaceMediaKey = MediaPlayerData.smartspaceMediaKey()
1015             existingSmartspaceMediaKey?.let {
1016                 val removedPlayer =
1017                     removePlayer(existingSmartspaceMediaKey, dismissMediaData = false)
1018                 removedPlayer?.run {
1019                     debugLogger.logPotentialMemoryLeak(existingSmartspaceMediaKey)
1020                     onDestroy()
1021                 }
1022             }
1023 
1024             val newRecs = mediaControlPanelFactory.get()
1025             newRecs.attachRecommendation(
1026                 RecommendationViewHolder.create(LayoutInflater.from(context), mediaContent)
1027             )
1028             newRecs.mediaViewController.sizeChangedListener = this::updateCarouselDimensions
1029             val lp =
1030                 LinearLayout.LayoutParams(
1031                     ViewGroup.LayoutParams.MATCH_PARENT,
1032                     ViewGroup.LayoutParams.WRAP_CONTENT
1033                 )
1034             newRecs.recommendationViewHolder?.recommendations?.setLayoutParams(lp)
1035             newRecs.bindRecommendation(data)
1036             val curVisibleMediaKey =
1037                 MediaPlayerData.visiblePlayerKeys()
1038                     .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
1039             MediaPlayerData.addMediaRecommendation(
1040                 key,
1041                 data,
1042                 newRecs,
1043                 shouldPrioritize,
1044                 systemClock,
1045                 debugLogger,
1046             )
1047             updateViewControllerToState(newRecs.mediaViewController, noAnimation = true)
1048             reorderAllPlayers(curVisibleMediaKey)
1049             updatePageIndicator()
1050             mediaFrame.requiresRemeasuring = true
1051             // Check postcondition: mediaContent should have the same number of children as there
1052             // are
1053             // elements in mediaPlayers.
1054             if (MediaPlayerData.players().size != mediaContent.childCount) {
1055                 Log.e(
1056                     TAG,
1057                     "Size of players list and number of views in carousel are out of sync. " +
1058                         "Players size is ${MediaPlayerData.players().size}. " +
1059                         "View count is ${mediaContent.childCount}."
1060                 )
1061             }
1062         }
1063 
1064     fun removePlayer(
1065         key: String,
1066         dismissMediaData: Boolean = true,
1067         dismissRecommendation: Boolean = true,
1068         userInitiated: Boolean = false,
1069     ): MediaControlPanel? {
1070         if (key == MediaPlayerData.smartspaceMediaKey()) {
1071             MediaPlayerData.smartspaceMediaData?.let {
1072                 logger.logRecommendationRemoved(it.packageName, it.instanceId)
1073             }
1074         }
1075         val removed =
1076             MediaPlayerData.removeMediaPlayer(key, dismissMediaData || dismissRecommendation)
1077         return removed?.apply {
1078             mediaCarouselScrollHandler.onPrePlayerRemoved(removed.mediaViewHolder?.player)
1079             mediaContent.removeView(removed.mediaViewHolder?.player)
1080             mediaContent.removeView(removed.recommendationViewHolder?.recommendations)
1081             removed.onDestroy()
1082             mediaCarouselScrollHandler.onPlayersChanged()
1083             updatePageIndicator()
1084 
1085             if (dismissMediaData) {
1086                 // Inform the media manager of a potentially late dismissal
1087                 mediaManager.dismissMediaData(key, delay = 0L, userInitiated = userInitiated)
1088             }
1089             if (dismissRecommendation) {
1090                 // Inform the media manager of a potentially late dismissal
1091                 mediaManager.dismissSmartspaceRecommendation(key, delay = 0L)
1092             }
1093         }
1094     }
1095 
1096     private fun updatePlayers(recreateMedia: Boolean) {
1097         if (mediaFlags.isSceneContainerEnabled()) {
1098             updateMediaPlayers(recreateMedia)
1099             return
1100         }
1101         pageIndicator.tintList =
1102             ColorStateList.valueOf(context.getColor(R.color.media_paging_indicator))
1103         val previousVisibleKey =
1104             MediaPlayerData.visiblePlayerKeys()
1105                 .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
1106 
1107         MediaPlayerData.mediaData().forEach { (key, data, isSsMediaRec) ->
1108             if (isSsMediaRec) {
1109                 val smartspaceMediaData = MediaPlayerData.smartspaceMediaData
1110                 removePlayer(key, dismissMediaData = false, dismissRecommendation = false)
1111                 smartspaceMediaData?.let {
1112                     addSmartspaceMediaRecommendations(
1113                         it.targetId,
1114                         it,
1115                         MediaPlayerData.shouldPrioritizeSs
1116                     )
1117                 }
1118             } else {
1119                 val isSsReactivated = MediaPlayerData.isSsReactivated(key)
1120                 if (recreateMedia) {
1121                     removePlayer(key, dismissMediaData = false, dismissRecommendation = false)
1122                 }
1123                 addOrUpdatePlayer(
1124                     key = key,
1125                     oldKey = null,
1126                     data = data,
1127                     isSsReactivated = isSsReactivated
1128                 )
1129             }
1130             if (recreateMedia) {
1131                 reorderAllPlayers(previousVisibleKey)
1132             }
1133         }
1134     }
1135 
1136     private fun updateMediaPlayers(recreateMedia: Boolean) {
1137         pageIndicator.tintList =
1138             ColorStateList.valueOf(context.getColor(R.color.media_paging_indicator))
1139         if (recreateMedia) {
1140             mediaContent.removeAllViews()
1141             commonViewModels.forEach { viewModel ->
1142                 when (viewModel) {
1143                     is MediaCommonViewModel.MediaControl -> {
1144                         controllerByViewModel[viewModel]?.mediaViewHolder?.let {
1145                             mediaContent.addView(it.player)
1146                         }
1147                     }
1148                     is MediaCommonViewModel.MediaRecommendations -> {
1149                         controllerByViewModel[viewModel]?.recommendationViewHolder?.let {
1150                             mediaContent.addView(it.recommendations)
1151                         }
1152                     }
1153                 }
1154             }
1155         }
1156     }
1157 
1158     private fun updatePageIndicator() {
1159         val numPages = mediaContent.getChildCount()
1160         pageIndicator.setNumPages(numPages)
1161         if (numPages == 1) {
1162             pageIndicator.setLocation(0f)
1163         }
1164         updatePageIndicatorAlpha()
1165     }
1166 
1167     /**
1168      * Set a new interpolated state for all players. This is a state that is usually controlled by a
1169      * finger movement where the user drags from one state to the next.
1170      *
1171      * @param startLocation the start location of our state or -1 if this is directly set
1172      * @param endLocation the ending location of our state.
1173      * @param progress the progress of the transition between startLocation and endlocation. If
1174      *
1175      * ```
1176      *                 this is not a guided transformation, this will be 1.0f
1177      * @param immediately
1178      * ```
1179      *
1180      * should this state be applied immediately, canceling all animations?
1181      */
1182     fun setCurrentState(
1183         @MediaLocation startLocation: Int,
1184         @MediaLocation endLocation: Int,
1185         progress: Float,
1186         immediately: Boolean
1187     ) {
1188         if (
1189             startLocation != currentStartLocation ||
1190                 endLocation != currentEndLocation ||
1191                 progress != currentTransitionProgress ||
1192                 immediately
1193         ) {
1194             currentStartLocation = startLocation
1195             currentEndLocation = endLocation
1196             currentTransitionProgress = progress
1197             if (!mediaFlags.isSceneContainerEnabled()) {
1198                 for (mediaPlayer in MediaPlayerData.players()) {
1199                     updateViewControllerToState(mediaPlayer.mediaViewController, immediately)
1200                 }
1201             } else {
1202                 controllerByViewModel.values.forEach {
1203                     updateViewControllerToState(it, immediately)
1204                 }
1205             }
1206             maybeResetSettingsCog()
1207             updatePageIndicatorAlpha()
1208         }
1209     }
1210 
1211     @VisibleForTesting
1212     fun updatePageIndicatorAlpha() {
1213         val hostStates = mediaHostStatesManager.mediaHostStates
1214         val endIsVisible = hostStates[currentEndLocation]?.visible ?: false
1215         val startIsVisible = hostStates[currentStartLocation]?.visible ?: false
1216         val startAlpha = if (startIsVisible) 1.0f else 0.0f
1217         // when squishing in split shade, only use endState, which keeps changing
1218         // to provide squishFraction
1219         val squishFraction = hostStates[currentEndLocation]?.squishFraction ?: 1.0F
1220         val endAlpha =
1221             (if (endIsVisible) 1.0f else 0.0f) *
1222                 calculateAlpha(
1223                     squishFraction,
1224                     (pageIndicator.translationY + pageIndicator.height) /
1225                         mediaCarousel.measuredHeight,
1226                     1F
1227                 )
1228         var alpha = 1.0f
1229         if (!endIsVisible || !startIsVisible) {
1230             var progress = currentTransitionProgress
1231             if (!endIsVisible) {
1232                 progress = 1.0f - progress
1233             }
1234             // Let's fade in quickly at the end where the view is visible
1235             progress =
1236                 MathUtils.constrain(MathUtils.map(0.95f, 1.0f, 0.0f, 1.0f, progress), 0.0f, 1.0f)
1237             alpha = MathUtils.lerp(startAlpha, endAlpha, progress)
1238         }
1239         pageIndicator.alpha = alpha
1240     }
1241 
1242     private fun updatePageIndicatorLocation() {
1243         // Update the location of the page indicator, carousel clipping
1244         val translationX =
1245             if (isRtl) {
1246                 (pageIndicator.width - currentCarouselWidth) / 2.0f
1247             } else {
1248                 (currentCarouselWidth - pageIndicator.width) / 2.0f
1249             }
1250         pageIndicator.translationX = translationX + mediaCarouselScrollHandler.contentTranslation
1251         val layoutParams = pageIndicator.layoutParams as ViewGroup.MarginLayoutParams
1252         pageIndicator.translationY =
1253             (mediaCarousel.measuredHeight - pageIndicator.height - layoutParams.bottomMargin)
1254                 .toFloat()
1255     }
1256 
1257     /** Update listening to seekbar. */
1258     private fun updateSeekbarListening(visibleToUser: Boolean) {
1259         if (!mediaFlags.isSceneContainerEnabled()) {
1260             for (player in MediaPlayerData.players()) {
1261                 player.setListening(visibleToUser && currentlyExpanded)
1262             }
1263         } else {
1264             controllerByViewModel.values.forEach {
1265                 it.setListening(visibleToUser && currentlyExpanded)
1266             }
1267         }
1268     }
1269 
1270     /** Update the dimension of this carousel. */
1271     private fun updateCarouselDimensions() {
1272         var width = 0
1273         var height = 0
1274         if (!mediaFlags.isSceneContainerEnabled()) {
1275             for (mediaPlayer in MediaPlayerData.players()) {
1276                 val controller = mediaPlayer.mediaViewController
1277                 // When transitioning the view to gone, the view gets smaller, but the translation
1278                 // Doesn't, let's add the translation
1279                 width = Math.max(width, controller.currentWidth + controller.translationX.toInt())
1280                 height =
1281                     Math.max(height, controller.currentHeight + controller.translationY.toInt())
1282             }
1283         } else {
1284             controllerByViewModel.values.forEach {
1285                 // When transitioning the view to gone, the view gets smaller, but the translation
1286                 // Doesn't, let's add the translation
1287                 width = Math.max(width, it.currentWidth + it.translationX.toInt())
1288                 height = Math.max(height, it.currentHeight + it.translationY.toInt())
1289             }
1290         }
1291         if (width != currentCarouselWidth || height != currentCarouselHeight) {
1292             currentCarouselWidth = width
1293             currentCarouselHeight = height
1294             mediaCarouselScrollHandler.setCarouselBounds(
1295                 currentCarouselWidth,
1296                 currentCarouselHeight
1297             )
1298             updatePageIndicatorLocation()
1299             updatePageIndicatorAlpha()
1300         }
1301     }
1302 
1303     private fun maybeResetSettingsCog() {
1304         val hostStates = mediaHostStatesManager.mediaHostStates
1305         val endShowsActive = hostStates[currentEndLocation]?.showsOnlyActiveMedia ?: true
1306         val startShowsActive =
1307             hostStates[currentStartLocation]?.showsOnlyActiveMedia ?: endShowsActive
1308         if (
1309             currentlyShowingOnlyActive != endShowsActive ||
1310                 ((currentTransitionProgress != 1.0f && currentTransitionProgress != 0.0f) &&
1311                     startShowsActive != endShowsActive)
1312         ) {
1313             // Whenever we're transitioning from between differing states or the endstate differs
1314             // we reset the translation
1315             currentlyShowingOnlyActive = endShowsActive
1316             mediaCarouselScrollHandler.resetTranslation(animate = true)
1317         }
1318     }
1319 
1320     private fun updateViewControllerToState(
1321         viewController: MediaViewController,
1322         noAnimation: Boolean
1323     ) {
1324         viewController.setCurrentState(
1325             startLocation = currentStartLocation,
1326             endLocation = currentEndLocation,
1327             transitionProgress = currentTransitionProgress,
1328             applyImmediately = noAnimation
1329         )
1330     }
1331 
1332     /**
1333      * The desired location of this view has changed. We should remeasure the view to match the new
1334      * bounds and kick off bounds animations if necessary. If an animation is happening, an
1335      * animation is kicked of externally, which sets a new current state until we reach the
1336      * targetState.
1337      *
1338      * @param desiredLocation the location we're going to
1339      * @param desiredHostState the target state we're transitioning to
1340      * @param animate should this be animated
1341      */
1342     fun onDesiredLocationChanged(
1343         desiredLocation: Int,
1344         desiredHostState: MediaHostState?,
1345         animate: Boolean,
1346         duration: Long = 200,
1347         startDelay: Long = 0
1348     ) =
1349         traceSection("MediaCarouselController#onDesiredLocationChanged") {
1350             desiredHostState?.let {
1351                 if (this.desiredLocation != desiredLocation) {
1352                     // Only log an event when location changes
1353                     bgExecutor.execute { logger.logCarouselPosition(desiredLocation) }
1354                 }
1355 
1356                 // This is a hosting view, let's remeasure our players
1357                 this.desiredLocation = desiredLocation
1358                 this.desiredHostState = it
1359                 currentlyExpanded = it.expansion > 0
1360 
1361                 val shouldCloseGuts =
1362                     !currentlyExpanded &&
1363                         !mediaManager.hasActiveMediaOrRecommendation() &&
1364                         desiredHostState.showsOnlyActiveMedia
1365 
1366                 if (!mediaFlags.isSceneContainerEnabled()) {
1367                     for (mediaPlayer in MediaPlayerData.players()) {
1368                         if (animate) {
1369                             mediaPlayer.mediaViewController.animatePendingStateChange(
1370                                 duration = duration,
1371                                 delay = startDelay
1372                             )
1373                         }
1374                         if (shouldCloseGuts && mediaPlayer.mediaViewController.isGutsVisible) {
1375                             mediaPlayer.closeGuts(!animate)
1376                         }
1377 
1378                         mediaPlayer.mediaViewController.onLocationPreChange(desiredLocation)
1379                     }
1380                 } else {
1381                     controllerByViewModel.values.forEach { controller ->
1382                         if (animate) {
1383                             controller.animatePendingStateChange(duration, startDelay)
1384                         }
1385                         if (shouldCloseGuts && controller.isGutsVisible) {
1386                             controller.closeGuts(!animate)
1387                         }
1388 
1389                         controller.onLocationPreChange(desiredLocation)
1390                     }
1391                 }
1392                 mediaCarouselScrollHandler.showsSettingsButton = !it.showsOnlyActiveMedia
1393                 mediaCarouselScrollHandler.falsingProtectionNeeded = it.falsingProtectionNeeded
1394                 val nowVisible = it.visible
1395                 if (nowVisible != playersVisible) {
1396                     playersVisible = nowVisible
1397                     if (nowVisible) {
1398                         mediaCarouselScrollHandler.resetTranslation()
1399                     }
1400                 }
1401                 updateCarouselSize()
1402             }
1403         }
1404 
1405     fun closeGuts(immediate: Boolean = true) {
1406         if (!mediaFlags.isSceneContainerEnabled()) {
1407             MediaPlayerData.players().forEach { it.closeGuts(immediate) }
1408         } else {
1409             controllerByViewModel.values.forEach { it.closeGuts(immediate) }
1410         }
1411     }
1412 
1413     /** Update the size of the carousel, remeasuring it if necessary. */
1414     private fun updateCarouselSize() {
1415         val width = desiredHostState?.measurementInput?.width ?: 0
1416         val height = desiredHostState?.measurementInput?.height ?: 0
1417         if (
1418             width != carouselMeasureWidth && width != 0 ||
1419                 height != carouselMeasureHeight && height != 0
1420         ) {
1421             carouselMeasureWidth = width
1422             carouselMeasureHeight = height
1423             val playerWidthPlusPadding =
1424                 carouselMeasureWidth +
1425                     context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
1426             // Let's remeasure the carousel
1427             val widthSpec = desiredHostState?.measurementInput?.widthMeasureSpec ?: 0
1428             val heightSpec = desiredHostState?.measurementInput?.heightMeasureSpec ?: 0
1429             mediaCarousel.measure(widthSpec, heightSpec)
1430             mediaCarousel.layout(0, 0, width, mediaCarousel.measuredHeight)
1431             // Update the padding after layout; view widths are used in RTL to calculate scrollX
1432             mediaCarouselScrollHandler.playerWidthPlusPadding = playerWidthPlusPadding
1433         }
1434     }
1435 
1436     /** Log the user impression for media card at visibleMediaIndex. */
1437     fun logSmartspaceImpression(qsExpanded: Boolean) {
1438         val visibleMediaIndex = mediaCarouselScrollHandler.visibleMediaIndex
1439         if (MediaPlayerData.players().size > visibleMediaIndex) {
1440             val mediaControlPanel = MediaPlayerData.getMediaControlPanel(visibleMediaIndex)
1441             val hasActiveMediaOrRecommendationCard =
1442                 MediaPlayerData.hasActiveMediaOrRecommendationCard()
1443             if (!hasActiveMediaOrRecommendationCard && !qsExpanded) {
1444                 // Skip logging if on LS or QQS, and there is no active media card
1445                 return
1446             }
1447             mediaControlPanel?.let {
1448                 logSmartspaceCardReported(
1449                     800, // SMARTSPACE_CARD_SEEN
1450                     it.mSmartspaceId,
1451                     it.mUid,
1452                     intArrayOf(it.surfaceForSmartspaceLogging)
1453                 )
1454                 it.mIsImpressed = true
1455             }
1456         }
1457     }
1458 
1459     /**
1460      * Log Smartspace events
1461      *
1462      * @param eventId UI event id (e.g. 800 for SMARTSPACE_CARD_SEEN)
1463      * @param instanceId id to uniquely identify a card, e.g. each headphone generates a new
1464      *   instanceId
1465      * @param uid uid for the application that media comes from
1466      * @param surfaces list of display surfaces the media card is on (e.g. lockscreen, shade) when
1467      *   the event happened
1468      * @param interactedSubcardRank the rank for interacted media item for recommendation card, -1
1469      *   for tapping on card but not on any media item, 0 for first media item, 1 for second, etc.
1470      * @param interactedSubcardCardinality how many media items were shown to the user when there is
1471      *   user interaction
1472      * @param rank the rank for media card in the media carousel, starting from 0
1473      * @param receivedLatencyMillis latency in milliseconds for card received events. E.g. latency
1474      *   between headphone connection to sysUI displays media recommendation card
1475      * @param isSwipeToDismiss whether is to log swipe-to-dismiss event
1476      */
1477     @JvmOverloads
1478     fun logSmartspaceCardReported(
1479         eventId: Int,
1480         instanceId: Int,
1481         uid: Int,
1482         surfaces: IntArray,
1483         interactedSubcardRank: Int = 0,
1484         interactedSubcardCardinality: Int = 0,
1485         rank: Int = mediaCarouselScrollHandler.visibleMediaIndex,
1486         receivedLatencyMillis: Int = 0,
1487         isSwipeToDismiss: Boolean = false
1488     ) {
1489         if (MediaPlayerData.players().size <= rank) {
1490             return
1491         }
1492 
1493         val mediaControlKey = MediaPlayerData.visiblePlayerKeys().elementAt(rank)
1494         // Only log media resume card when Smartspace data is available
1495         if (
1496             !mediaControlKey.isSsMediaRec &&
1497                 !mediaManager.isRecommendationActive() &&
1498                 MediaPlayerData.smartspaceMediaData == null
1499         ) {
1500             return
1501         }
1502 
1503         val cardinality = mediaContent.getChildCount()
1504         surfaces.forEach { surface ->
1505             SysUiStatsLog.write(
1506                 SMARTSPACE_CARD_REPORTED,
1507                 eventId,
1508                 instanceId,
1509                 // Deprecated, replaced with AiAi feature type so we don't need to create logging
1510                 // card type for each new feature.
1511                 SMART_SPACE_CARD_REPORTED__CARD_TYPE__UNKNOWN_CARD,
1512                 surface,
1513                 // Use -1 as rank value to indicate user swipe to dismiss the card
1514                 if (isSwipeToDismiss) -1 else rank,
1515                 cardinality,
1516                 if (mediaControlKey.isSsMediaRec) {
1517                     15 // MEDIA_RECOMMENDATION
1518                 } else if (mediaControlKey.isSsReactivated) {
1519                     43 // MEDIA_RESUME_SS_ACTIVATED
1520                 } else {
1521                     31
1522                 }, // MEDIA_RESUME
1523                 uid,
1524                 interactedSubcardRank,
1525                 interactedSubcardCardinality,
1526                 receivedLatencyMillis,
1527                 null, // Media cards cannot have subcards.
1528                 null // Media cards don't have dimensions today.
1529             )
1530 
1531             if (DEBUG) {
1532                 Log.d(
1533                     TAG,
1534                     "Log Smartspace card event id: $eventId instance id: $instanceId" +
1535                         " surface: $surface rank: $rank cardinality: $cardinality " +
1536                         "isRecommendationCard: ${mediaControlKey.isSsMediaRec} " +
1537                         "isSsReactivated: ${mediaControlKey.isSsReactivated}" +
1538                         "uid: $uid " +
1539                         "interactedSubcardRank: $interactedSubcardRank " +
1540                         "interactedSubcardCardinality: $interactedSubcardCardinality " +
1541                         "received_latency_millis: $receivedLatencyMillis"
1542                 )
1543             }
1544         }
1545     }
1546 
1547     @VisibleForTesting
1548     fun onSwipeToDismiss() {
1549         if (mediaFlags.isSceneContainerEnabled()) {
1550             mediaCarouselViewModel.onSwipeToDismiss()
1551             return
1552         }
1553         MediaPlayerData.players().forEachIndexed { index, it ->
1554             if (it.mIsImpressed) {
1555                 logSmartspaceCardReported(
1556                     SMARTSPACE_CARD_DISMISS_EVENT,
1557                     it.mSmartspaceId,
1558                     it.mUid,
1559                     intArrayOf(it.surfaceForSmartspaceLogging),
1560                     rank = index,
1561                     isSwipeToDismiss = true
1562                 )
1563                 // Reset card impressed state when swipe to dismissed
1564                 it.mIsImpressed = false
1565             }
1566         }
1567         MediaPlayerData.isSwipedAway = true
1568         logger.logSwipeDismiss()
1569         mediaManager.onSwipeToDismiss()
1570     }
1571 
1572     fun getCurrentVisibleMediaContentIntent(): PendingIntent? {
1573         return MediaPlayerData.playerKeys()
1574             .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
1575             ?.data
1576             ?.clickIntent
1577     }
1578 
1579     override fun dump(pw: PrintWriter, args: Array<out String>) {
1580         pw.apply {
1581             println("keysNeedRemoval: $keysNeedRemoval")
1582             println("dataKeys: ${MediaPlayerData.dataKeys()}")
1583             println("orderedPlayerSortKeys: ${MediaPlayerData.playerKeys()}")
1584             println("visiblePlayerSortKeys: ${MediaPlayerData.visiblePlayerKeys()}")
1585             println("commonViewModels: $commonViewModels")
1586             println("smartspaceMediaData: ${MediaPlayerData.smartspaceMediaData}")
1587             println("shouldPrioritizeSs: ${MediaPlayerData.shouldPrioritizeSs}")
1588             println("current size: $currentCarouselWidth x $currentCarouselHeight")
1589             println("location: $desiredLocation")
1590             println(
1591                 "state: ${desiredHostState?.expansion}, " +
1592                     "only active ${desiredHostState?.showsOnlyActiveMedia}"
1593             )
1594             println("isSwipedAway: ${MediaPlayerData.isSwipedAway}")
1595         }
1596     }
1597 }
1598 
1599 @VisibleForTesting
1600 internal object MediaPlayerData {
1601     private val EMPTY =
1602         MediaData(
1603             userId = -1,
1604             initialized = false,
1605             app = null,
1606             appIcon = null,
1607             artist = null,
1608             song = null,
1609             artwork = null,
1610             actions = emptyList(),
1611             actionsToShowInCompact = emptyList(),
1612             packageName = "INVALID",
1613             token = null,
1614             clickIntent = null,
1615             device = null,
1616             active = true,
1617             resumeAction = null,
1618             instanceId = InstanceId.fakeInstanceId(-1),
1619             appUid = -1
1620         )
1621 
1622     // Whether should prioritize Smartspace card.
1623     internal var shouldPrioritizeSs: Boolean = false
1624         private set
1625 
1626     internal var smartspaceMediaData: SmartspaceMediaData? = null
1627         private set
1628 
1629     data class MediaSortKey(
1630         val isSsMediaRec: Boolean, // Whether the item represents a Smartspace media recommendation.
1631         val data: MediaData,
1632         val key: String,
1633         val updateTime: Long = 0,
1634         val isSsReactivated: Boolean = false,
1635     )
1636 
1637     private val comparator =
<lambda>null1638         compareByDescending<MediaSortKey> {
1639                 it.data.isPlaying == true && it.data.playbackLocation == MediaData.PLAYBACK_LOCAL
1640             }
<lambda>null1641             .thenByDescending {
1642                 it.data.isPlaying == true &&
1643                     it.data.playbackLocation == MediaData.PLAYBACK_CAST_LOCAL
1644             }
<lambda>null1645             .thenByDescending { it.data.active }
<lambda>null1646             .thenByDescending { shouldPrioritizeSs == it.isSsMediaRec }
<lambda>null1647             .thenByDescending { !it.data.resumption }
<lambda>null1648             .thenByDescending { it.data.playbackLocation != MediaData.PLAYBACK_CAST_REMOTE }
<lambda>null1649             .thenByDescending { it.data.lastActive }
<lambda>null1650             .thenByDescending { it.updateTime }
<lambda>null1651             .thenByDescending { it.data.notificationKey }
1652 
1653     private val mediaPlayers = TreeMap<MediaSortKey, MediaControlPanel>(comparator)
1654     private val mediaData: MutableMap<String, MediaSortKey> = mutableMapOf()
1655 
1656     // A map that tracks order of visible media players before they get reordered.
1657     private val visibleMediaPlayers = LinkedHashMap<String, MediaSortKey>()
1658 
1659     // Whether the user swiped away the carousel since its last update
1660     internal var isSwipedAway: Boolean = false
1661 
addMediaPlayernull1662     fun addMediaPlayer(
1663         key: String,
1664         data: MediaData,
1665         player: MediaControlPanel,
1666         clock: SystemClock,
1667         isSsReactivated: Boolean,
1668         debugLogger: MediaCarouselControllerLogger? = null
1669     ) {
1670         val removedPlayer = removeMediaPlayer(key)
1671         if (removedPlayer != null && removedPlayer != player) {
1672             debugLogger?.logPotentialMemoryLeak(key)
1673             removedPlayer.onDestroy()
1674         }
1675         val sortKey =
1676             MediaSortKey(
1677                 isSsMediaRec = false,
1678                 data,
1679                 key,
1680                 clock.currentTimeMillis(),
1681                 isSsReactivated = isSsReactivated
1682             )
1683         mediaData.put(key, sortKey)
1684         mediaPlayers.put(sortKey, player)
1685         visibleMediaPlayers.put(key, sortKey)
1686     }
1687 
addMediaRecommendationnull1688     fun addMediaRecommendation(
1689         key: String,
1690         data: SmartspaceMediaData,
1691         player: MediaControlPanel,
1692         shouldPrioritize: Boolean,
1693         clock: SystemClock,
1694         debugLogger: MediaCarouselControllerLogger? = null,
1695         update: Boolean = false
1696     ) {
1697         shouldPrioritizeSs = shouldPrioritize
1698         val removedPlayer = removeMediaPlayer(key)
1699         if (!update && removedPlayer != null && removedPlayer != player) {
1700             debugLogger?.logPotentialMemoryLeak(key)
1701             removedPlayer.onDestroy()
1702         }
1703         val sortKey =
1704             MediaSortKey(
1705                 isSsMediaRec = true,
1706                 EMPTY.copy(active = data.isActive, isPlaying = false),
1707                 key,
1708                 clock.currentTimeMillis(),
1709                 isSsReactivated = true
1710             )
1711         mediaData.put(key, sortKey)
1712         mediaPlayers.put(sortKey, player)
1713         visibleMediaPlayers.put(key, sortKey)
1714         smartspaceMediaData = data
1715     }
1716 
moveIfExistsnull1717     fun moveIfExists(
1718         oldKey: String?,
1719         newKey: String,
1720         debugLogger: MediaCarouselControllerLogger? = null
1721     ) {
1722         if (oldKey == null || oldKey == newKey) {
1723             return
1724         }
1725 
1726         mediaData.remove(oldKey)?.let {
1727             // MediaPlayer should not be visible
1728             // no need to set isDismissed flag.
1729             val removedPlayer = removeMediaPlayer(newKey)
1730             removedPlayer?.run {
1731                 debugLogger?.logPotentialMemoryLeak(newKey)
1732                 onDestroy()
1733             }
1734             mediaData.put(newKey, it)
1735         }
1736     }
1737 
getMediaControlPanelnull1738     fun getMediaControlPanel(visibleIndex: Int): MediaControlPanel? {
1739         return mediaPlayers.get(visiblePlayerKeys().elementAt(visibleIndex))
1740     }
1741 
getMediaPlayernull1742     fun getMediaPlayer(key: String): MediaControlPanel? {
1743         return mediaData.get(key)?.let { mediaPlayers.get(it) }
1744     }
1745 
getMediaPlayerIndexnull1746     fun getMediaPlayerIndex(key: String): Int {
1747         val sortKey = mediaData.get(key)
1748         mediaPlayers.entries.forEachIndexed { index, e ->
1749             if (e.key == sortKey) {
1750                 return index
1751             }
1752         }
1753         return -1
1754     }
1755 
1756     /**
1757      * Removes media player given the key.
1758      *
1759      * @param isDismissed determines whether the media player is removed from the carousel.
1760      */
removeMediaPlayernull1761     fun removeMediaPlayer(key: String, isDismissed: Boolean = false) =
1762         mediaData.remove(key)?.let {
1763             if (it.isSsMediaRec) {
1764                 smartspaceMediaData = null
1765             }
1766             if (isDismissed) {
1767                 visibleMediaPlayers.remove(key)
1768             }
1769             mediaPlayers.remove(it)
1770         }
1771 
mediaDatanull1772     fun mediaData() =
1773         mediaData.entries.map { e -> Triple(e.key, e.value.data, e.value.isSsMediaRec) }
1774 
dataKeysnull1775     fun dataKeys() = mediaData.keys
1776 
1777     fun players() = mediaPlayers.values
1778 
1779     fun playerKeys() = mediaPlayers.keys
1780 
1781     fun visiblePlayerKeys() = visibleMediaPlayers.values
1782 
1783     /** Returns the index of the first non-timeout media. */
1784     fun firstActiveMediaIndex(): Int {
1785         mediaPlayers.entries.forEachIndexed { index, e ->
1786             if (!e.key.isSsMediaRec && e.key.data.active) {
1787                 return index
1788             }
1789         }
1790         return -1
1791     }
1792 
1793     /** Returns the existing Smartspace target id. */
smartspaceMediaKeynull1794     fun smartspaceMediaKey(): String? {
1795         mediaData.entries.forEach { e ->
1796             if (e.value.isSsMediaRec) {
1797                 return e.key
1798             }
1799         }
1800         return null
1801     }
1802 
1803     @VisibleForTesting
clearnull1804     fun clear() {
1805         mediaData.clear()
1806         mediaPlayers.clear()
1807         visibleMediaPlayers.clear()
1808     }
1809 
1810     /* Returns true if there is active media player card or recommendation card */
hasActiveMediaOrRecommendationCardnull1811     fun hasActiveMediaOrRecommendationCard(): Boolean {
1812         if (smartspaceMediaData != null && smartspaceMediaData?.isActive!!) {
1813             return true
1814         }
1815         if (firstActiveMediaIndex() != -1) {
1816             return true
1817         }
1818         return false
1819     }
1820 
isSsReactivatednull1821     fun isSsReactivated(key: String): Boolean = mediaData.get(key)?.isSsReactivated ?: false
1822 
1823     /**
1824      * This method is called when media players are reordered. To make sure we have the new version
1825      * of the order of media players visible to user.
1826      */
1827     fun updateVisibleMediaPlayers() {
1828         visibleMediaPlayers.clear()
1829         playerKeys().forEach { visibleMediaPlayers.put(it.key, it) }
1830     }
1831 }
1832