<lambda>null1 package com.android.systemui.media
2
3 import android.content.Context
4 import android.content.Intent
5 import android.content.res.Configuration
6 import android.graphics.Color
7 import android.provider.Settings.ACTION_MEDIA_CONTROLS_SETTINGS
8 import android.util.Log
9 import android.util.MathUtils
10 import android.view.LayoutInflater
11 import android.view.View
12 import android.view.ViewGroup
13 import android.widget.LinearLayout
14 import androidx.annotation.VisibleForTesting
15 import com.android.systemui.R
16 import com.android.systemui.dagger.qualifiers.Main
17 import com.android.systemui.plugins.ActivityStarter
18 import com.android.systemui.plugins.FalsingManager
19 import com.android.systemui.qs.PageIndicator
20 import com.android.systemui.statusbar.notification.VisualStabilityManager
21 import com.android.systemui.statusbar.policy.ConfigurationController
22 import com.android.systemui.util.Utils
23 import com.android.systemui.util.animation.UniqueObjectHostView
24 import com.android.systemui.util.animation.requiresRemeasuring
25 import com.android.systemui.util.concurrency.DelayableExecutor
26 import java.util.TreeMap
27 import javax.inject.Inject
28 import javax.inject.Provider
29 import javax.inject.Singleton
30
31 private const val TAG = "MediaCarouselController"
32 private val settingsIntent = Intent().setAction(ACTION_MEDIA_CONTROLS_SETTINGS)
33
34 /**
35 * Class that is responsible for keeping the view carousel up to date.
36 * This also handles changes in state and applies them to the media carousel like the expansion.
37 */
38 @Singleton
39 class MediaCarouselController @Inject constructor(
40 private val context: Context,
41 private val mediaControlPanelFactory: Provider<MediaControlPanel>,
42 private val visualStabilityManager: VisualStabilityManager,
43 private val mediaHostStatesManager: MediaHostStatesManager,
44 private val activityStarter: ActivityStarter,
45 @Main executor: DelayableExecutor,
46 private val mediaManager: MediaDataManager,
47 configurationController: ConfigurationController,
48 falsingManager: FalsingManager
49 ) {
50 /**
51 * The current width of the carousel
52 */
53 private var currentCarouselWidth: Int = 0
54
55 /**
56 * The current height of the carousel
57 */
58 private var currentCarouselHeight: Int = 0
59
60 /**
61 * Are we currently showing only active players
62 */
63 private var currentlyShowingOnlyActive: Boolean = false
64
65 /**
66 * Is the player currently visible (at the end of the transformation
67 */
68 private var playersVisible: Boolean = false
69 /**
70 * The desired location where we'll be at the end of the transformation. Usually this matches
71 * the end location, except when we're still waiting on a state update call.
72 */
73 @MediaLocation
74 private var desiredLocation: Int = -1
75
76 /**
77 * The ending location of the view where it ends when all animations and transitions have
78 * finished
79 */
80 @MediaLocation
81 private var currentEndLocation: Int = -1
82
83 /**
84 * The ending location of the view where it ends when all animations and transitions have
85 * finished
86 */
87 @MediaLocation
88 private var currentStartLocation: Int = -1
89
90 /**
91 * The progress of the transition or 1.0 if there is no transition happening
92 */
93 private var currentTransitionProgress: Float = 1.0f
94
95 /**
96 * The measured width of the carousel
97 */
98 private var carouselMeasureWidth: Int = 0
99
100 /**
101 * The measured height of the carousel
102 */
103 private var carouselMeasureHeight: Int = 0
104 private var desiredHostState: MediaHostState? = null
105 private val mediaCarousel: MediaScrollView
106 private val mediaCarouselScrollHandler: MediaCarouselScrollHandler
107 val mediaFrame: ViewGroup
108 private lateinit var settingsButton: View
109 private val mediaContent: ViewGroup
110 private val pageIndicator: PageIndicator
111 private val visualStabilityCallback: VisualStabilityManager.Callback
112 private var needsReordering: Boolean = false
113 private var keysNeedRemoval = mutableSetOf<String>()
114 private var isRtl: Boolean = false
115 set(value) {
116 if (value != field) {
117 field = value
118 mediaFrame.layoutDirection =
119 if (value) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR
120 mediaCarouselScrollHandler.scrollToStart()
121 }
122 }
123 private var currentlyExpanded = true
124 set(value) {
125 if (field != value) {
126 field = value
127 for (player in MediaPlayerData.players()) {
128 player.setListening(field)
129 }
130 }
131 }
132 private val configListener = object : ConfigurationController.ConfigurationListener {
133 override fun onDensityOrFontScaleChanged() {
134 recreatePlayers()
135 inflateSettingsButton()
136 }
137
138 override fun onOverlayChanged() {
139 recreatePlayers()
140 inflateSettingsButton()
141 }
142
143 override fun onConfigChanged(newConfig: Configuration?) {
144 if (newConfig == null) return
145 isRtl = newConfig.layoutDirection == View.LAYOUT_DIRECTION_RTL
146 }
147 }
148
149 init {
150 mediaFrame = inflateMediaCarousel()
151 mediaCarousel = mediaFrame.requireViewById(R.id.media_carousel_scroller)
152 pageIndicator = mediaFrame.requireViewById(R.id.media_page_indicator)
153 mediaCarouselScrollHandler = MediaCarouselScrollHandler(mediaCarousel, pageIndicator,
154 executor, mediaManager::onSwipeToDismiss, this::updatePageIndicatorLocation,
155 this::closeGuts, falsingManager)
156 isRtl = context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
157 inflateSettingsButton()
158 mediaContent = mediaCarousel.requireViewById(R.id.media_carousel)
159 configurationController.addCallback(configListener)
160 visualStabilityCallback = VisualStabilityManager.Callback {
161 if (needsReordering) {
162 needsReordering = false
163 reorderAllPlayers()
164 }
165
166 keysNeedRemoval.forEach { removePlayer(it) }
167 keysNeedRemoval.clear()
168
169 // Let's reset our scroll position
170 mediaCarouselScrollHandler.scrollToStart()
171 }
172 visualStabilityManager.addReorderingAllowedCallback(visualStabilityCallback,
173 true /* persistent */)
174 mediaManager.addListener(object : MediaDataManager.Listener {
175 override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
176 addOrUpdatePlayer(key, oldKey, data)
177 val canRemove = data.isPlaying?.let { !it } ?: data.isClearable && !data.active
178 if (canRemove && !Utils.useMediaResumption(context)) {
179 // This view isn't playing, let's remove this! This happens e.g when
180 // dismissing/timing out a view. We still have the data around because
181 // resumption could be on, but we should save the resources and release this.
182 if (visualStabilityManager.isReorderingAllowed) {
183 onMediaDataRemoved(key)
184 } else {
185 keysNeedRemoval.add(key)
186 }
187 } else {
188 keysNeedRemoval.remove(key)
189 }
190 }
191
192 override fun onMediaDataRemoved(key: String) {
193 removePlayer(key)
194 }
195 })
196 mediaFrame.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
197 // The pageIndicator is not laid out yet when we get the current state update,
198 // Lets make sure we have the right dimensions
199 updatePageIndicatorLocation()
200 }
201 mediaHostStatesManager.addCallback(object : MediaHostStatesManager.Callback {
202 override fun onHostStateChanged(location: Int, mediaHostState: MediaHostState) {
203 if (location == desiredLocation) {
204 onDesiredLocationChanged(desiredLocation, mediaHostState, animate = false)
205 }
206 }
207 })
208 }
209
210 private fun inflateSettingsButton() {
211 val settings = LayoutInflater.from(context).inflate(R.layout.media_carousel_settings_button,
212 mediaFrame, false) as View
213 if (this::settingsButton.isInitialized) {
214 mediaFrame.removeView(settingsButton)
215 }
216 settingsButton = settings
217 mediaFrame.addView(settingsButton)
218 mediaCarouselScrollHandler.onSettingsButtonUpdated(settings)
219 settingsButton.setOnClickListener {
220 activityStarter.startActivity(settingsIntent, true /* dismissShade */)
221 }
222 }
223
224 private fun inflateMediaCarousel(): ViewGroup {
225 val mediaCarousel = LayoutInflater.from(context).inflate(R.layout.media_carousel,
226 UniqueObjectHostView(context), false) as ViewGroup
227 // Because this is inflated when not attached to the true view hierarchy, it resolves some
228 // potential issues to force that the layout direction is defined by the locale
229 // (rather than inherited from the parent, which would resolve to LTR when unattached).
230 mediaCarousel.layoutDirection = View.LAYOUT_DIRECTION_LOCALE
231 return mediaCarousel
232 }
233
234 private fun reorderAllPlayers() {
235 mediaContent.removeAllViews()
236 for (mediaPlayer in MediaPlayerData.players()) {
237 mediaPlayer.view?.let {
238 mediaContent.addView(it.player)
239 }
240 }
241 mediaCarouselScrollHandler.onPlayersChanged()
242 }
243
244 private fun addOrUpdatePlayer(key: String, oldKey: String?, data: MediaData) {
245 val existingPlayer = MediaPlayerData.getMediaPlayer(key, oldKey)
246 if (existingPlayer == null) {
247 var newPlayer = mediaControlPanelFactory.get()
248 newPlayer.attach(PlayerViewHolder.create(LayoutInflater.from(context), mediaContent))
249 newPlayer.mediaViewController.sizeChangedListener = this::updateCarouselDimensions
250 val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
251 ViewGroup.LayoutParams.WRAP_CONTENT)
252 newPlayer.view?.player?.setLayoutParams(lp)
253 newPlayer.bind(data, key)
254 newPlayer.setListening(currentlyExpanded)
255 MediaPlayerData.addMediaPlayer(key, data, newPlayer)
256 updatePlayerToState(newPlayer, noAnimation = true)
257 reorderAllPlayers()
258 } else {
259 existingPlayer.bind(data, key)
260 MediaPlayerData.addMediaPlayer(key, data, existingPlayer)
261 if (visualStabilityManager.isReorderingAllowed) {
262 reorderAllPlayers()
263 } else {
264 needsReordering = true
265 }
266 }
267 updatePageIndicator()
268 mediaCarouselScrollHandler.onPlayersChanged()
269 mediaCarousel.requiresRemeasuring = true
270 // Check postcondition: mediaContent should have the same number of children as there are
271 // elements in mediaPlayers.
272 if (MediaPlayerData.players().size != mediaContent.childCount) {
273 Log.wtf(TAG, "Size of players list and number of views in carousel are out of sync")
274 }
275 }
276
277 private fun removePlayer(key: String, dismissMediaData: Boolean = true) {
278 val removed = MediaPlayerData.removeMediaPlayer(key)
279 removed?.apply {
280 mediaCarouselScrollHandler.onPrePlayerRemoved(removed)
281 mediaContent.removeView(removed.view?.player)
282 removed.onDestroy()
283 mediaCarouselScrollHandler.onPlayersChanged()
284 updatePageIndicator()
285
286 if (dismissMediaData) {
287 // Inform the media manager of a potentially late dismissal
288 mediaManager.dismissMediaData(key, 0L)
289 }
290 }
291 }
292
293 private fun recreatePlayers() {
294 MediaPlayerData.mediaData().forEach { (key, data) ->
295 removePlayer(key, dismissMediaData = false)
296 addOrUpdatePlayer(key = key, oldKey = null, data = data)
297 }
298 }
299
300 private fun updatePageIndicator() {
301 val numPages = mediaContent.getChildCount()
302 pageIndicator.setNumPages(numPages, Color.WHITE)
303 if (numPages == 1) {
304 pageIndicator.setLocation(0f)
305 }
306 updatePageIndicatorAlpha()
307 }
308
309 /**
310 * Set a new interpolated state for all players. This is a state that is usually controlled
311 * by a finger movement where the user drags from one state to the next.
312 *
313 * @param startLocation the start location of our state or -1 if this is directly set
314 * @param endLocation the ending location of our state.
315 * @param progress the progress of the transition between startLocation and endlocation. If
316 * this is not a guided transformation, this will be 1.0f
317 * @param immediately should this state be applied immediately, canceling all animations?
318 */
319 fun setCurrentState(
320 @MediaLocation startLocation: Int,
321 @MediaLocation endLocation: Int,
322 progress: Float,
323 immediately: Boolean
324 ) {
325 if (startLocation != currentStartLocation ||
326 endLocation != currentEndLocation ||
327 progress != currentTransitionProgress ||
328 immediately
329 ) {
330 currentStartLocation = startLocation
331 currentEndLocation = endLocation
332 currentTransitionProgress = progress
333 for (mediaPlayer in MediaPlayerData.players()) {
334 updatePlayerToState(mediaPlayer, immediately)
335 }
336 maybeResetSettingsCog()
337 updatePageIndicatorAlpha()
338 }
339 }
340
341 private fun updatePageIndicatorAlpha() {
342 val hostStates = mediaHostStatesManager.mediaHostStates
343 val endIsVisible = hostStates[currentEndLocation]?.visible ?: false
344 val startIsVisible = hostStates[currentStartLocation]?.visible ?: false
345 val startAlpha = if (startIsVisible) 1.0f else 0.0f
346 val endAlpha = if (endIsVisible) 1.0f else 0.0f
347 var alpha = 1.0f
348 if (!endIsVisible || !startIsVisible) {
349 var progress = currentTransitionProgress
350 if (!endIsVisible) {
351 progress = 1.0f - progress
352 }
353 // Let's fade in quickly at the end where the view is visible
354 progress = MathUtils.constrain(
355 MathUtils.map(0.95f, 1.0f, 0.0f, 1.0f, progress),
356 0.0f,
357 1.0f)
358 alpha = MathUtils.lerp(startAlpha, endAlpha, progress)
359 }
360 pageIndicator.alpha = alpha
361 }
362
363 private fun updatePageIndicatorLocation() {
364 // Update the location of the page indicator, carousel clipping
365 val translationX = if (isRtl) {
366 (pageIndicator.width - currentCarouselWidth) / 2.0f
367 } else {
368 (currentCarouselWidth - pageIndicator.width) / 2.0f
369 }
370 pageIndicator.translationX = translationX + mediaCarouselScrollHandler.contentTranslation
371 val layoutParams = pageIndicator.layoutParams as ViewGroup.MarginLayoutParams
372 pageIndicator.translationY = (currentCarouselHeight - pageIndicator.height -
373 layoutParams.bottomMargin).toFloat()
374 }
375
376 /**
377 * Update the dimension of this carousel.
378 */
379 private fun updateCarouselDimensions() {
380 var width = 0
381 var height = 0
382 for (mediaPlayer in MediaPlayerData.players()) {
383 val controller = mediaPlayer.mediaViewController
384 // When transitioning the view to gone, the view gets smaller, but the translation
385 // Doesn't, let's add the translation
386 width = Math.max(width, controller.currentWidth + controller.translationX.toInt())
387 height = Math.max(height, controller.currentHeight + controller.translationY.toInt())
388 }
389 if (width != currentCarouselWidth || height != currentCarouselHeight) {
390 currentCarouselWidth = width
391 currentCarouselHeight = height
392 mediaCarouselScrollHandler.setCarouselBounds(
393 currentCarouselWidth, currentCarouselHeight)
394 updatePageIndicatorLocation()
395 }
396 }
397
398 private fun maybeResetSettingsCog() {
399 val hostStates = mediaHostStatesManager.mediaHostStates
400 val endShowsActive = hostStates[currentEndLocation]?.showsOnlyActiveMedia
401 ?: true
402 val startShowsActive = hostStates[currentStartLocation]?.showsOnlyActiveMedia
403 ?: endShowsActive
404 if (currentlyShowingOnlyActive != endShowsActive ||
405 ((currentTransitionProgress != 1.0f && currentTransitionProgress != 0.0f) &&
406 startShowsActive != endShowsActive)) {
407 // Whenever we're transitioning from between differing states or the endstate differs
408 // we reset the translation
409 currentlyShowingOnlyActive = endShowsActive
410 mediaCarouselScrollHandler.resetTranslation(animate = true)
411 }
412 }
413
414 private fun updatePlayerToState(mediaPlayer: MediaControlPanel, noAnimation: Boolean) {
415 mediaPlayer.mediaViewController.setCurrentState(
416 startLocation = currentStartLocation,
417 endLocation = currentEndLocation,
418 transitionProgress = currentTransitionProgress,
419 applyImmediately = noAnimation)
420 }
421
422 /**
423 * The desired location of this view has changed. We should remeasure the view to match
424 * the new bounds and kick off bounds animations if necessary.
425 * If an animation is happening, an animation is kicked of externally, which sets a new
426 * current state until we reach the targetState.
427 *
428 * @param desiredLocation the location we're going to
429 * @param desiredHostState the target state we're transitioning to
430 * @param animate should this be animated
431 */
432 fun onDesiredLocationChanged(
433 desiredLocation: Int,
434 desiredHostState: MediaHostState?,
435 animate: Boolean,
436 duration: Long = 200,
437 startDelay: Long = 0
438 ) {
439 desiredHostState?.let {
440 // This is a hosting view, let's remeasure our players
441 this.desiredLocation = desiredLocation
442 this.desiredHostState = it
443 currentlyExpanded = it.expansion > 0
444 for (mediaPlayer in MediaPlayerData.players()) {
445 if (animate) {
446 mediaPlayer.mediaViewController.animatePendingStateChange(
447 duration = duration,
448 delay = startDelay)
449 }
450 mediaPlayer.mediaViewController.onLocationPreChange(desiredLocation)
451 }
452 mediaCarouselScrollHandler.showsSettingsButton = !it.showsOnlyActiveMedia
453 mediaCarouselScrollHandler.falsingProtectionNeeded = it.falsingProtectionNeeded
454 val nowVisible = it.visible
455 if (nowVisible != playersVisible) {
456 playersVisible = nowVisible
457 if (nowVisible) {
458 mediaCarouselScrollHandler.resetTranslation()
459 }
460 }
461 updateCarouselSize()
462 }
463 }
464
465 fun closeGuts() {
466 MediaPlayerData.players().forEach {
467 it.closeGuts(true)
468 }
469 }
470
471 /**
472 * Update the size of the carousel, remeasuring it if necessary.
473 */
474 private fun updateCarouselSize() {
475 val width = desiredHostState?.measurementInput?.width ?: 0
476 val height = desiredHostState?.measurementInput?.height ?: 0
477 if (width != carouselMeasureWidth && width != 0 ||
478 height != carouselMeasureHeight && height != 0) {
479 carouselMeasureWidth = width
480 carouselMeasureHeight = height
481 val playerWidthPlusPadding = carouselMeasureWidth +
482 context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
483 // Let's remeasure the carousel
484 val widthSpec = desiredHostState?.measurementInput?.widthMeasureSpec ?: 0
485 val heightSpec = desiredHostState?.measurementInput?.heightMeasureSpec ?: 0
486 mediaCarousel.measure(widthSpec, heightSpec)
487 mediaCarousel.layout(0, 0, width, mediaCarousel.measuredHeight)
488 // Update the padding after layout; view widths are used in RTL to calculate scrollX
489 mediaCarouselScrollHandler.playerWidthPlusPadding = playerWidthPlusPadding
490 }
491 }
492 }
493
494 @VisibleForTesting
495 internal object MediaPlayerData {
496 private data class MediaSortKey(
497 val data: MediaData,
498 val updateTime: Long = 0
499 )
500
501 private val comparator =
<lambda>null502 compareByDescending<MediaSortKey> { it.data.isPlaying }
<lambda>null503 .thenByDescending { it.data.isLocalSession }
<lambda>null504 .thenByDescending { !it.data.resumption }
<lambda>null505 .thenByDescending { it.updateTime }
506
507 private val mediaPlayers = TreeMap<MediaSortKey, MediaControlPanel>(comparator)
508 private val mediaData: MutableMap<String, MediaSortKey> = mutableMapOf()
509
addMediaPlayernull510 fun addMediaPlayer(key: String, data: MediaData, player: MediaControlPanel) {
511 removeMediaPlayer(key)
512 val sortKey = MediaSortKey(data, System.currentTimeMillis())
513 mediaData.put(key, sortKey)
514 mediaPlayers.put(sortKey, player)
515 }
516
getMediaPlayernull517 fun getMediaPlayer(key: String, oldKey: String?): MediaControlPanel? {
518 // If the key was changed, update entry
519 oldKey?.let {
520 if (it != key) {
521 mediaData.remove(it)?.let { sortKey -> mediaData.put(key, sortKey) }
522 }
523 }
524 return mediaData.get(key)?.let { mediaPlayers.get(it) }
525 }
526
<lambda>null527 fun removeMediaPlayer(key: String) = mediaData.remove(key)?.let { mediaPlayers.remove(it) }
528
mediaDatanull529 fun mediaData() = mediaData.entries.map { e -> Pair(e.key, e.value.data) }
530
playersnull531 fun players() = mediaPlayers.values
532
533 @VisibleForTesting
534 fun clear() {
535 mediaData.clear()
536 mediaPlayers.clear()
537 }
538 }
539