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