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