• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 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.view
18 
19 import android.graphics.Outline
20 import android.util.MathUtils
21 import android.view.GestureDetector
22 import android.view.MotionEvent
23 import android.view.View
24 import android.view.ViewGroup
25 import android.view.ViewOutlineProvider
26 import androidx.annotation.VisibleForTesting
27 import androidx.core.view.GestureDetectorCompat
28 import androidx.dynamicanimation.animation.FloatPropertyCompat
29 import androidx.dynamicanimation.animation.SpringForce
30 import com.android.app.tracing.TraceStateLogger
31 import com.android.settingslib.Utils
32 import com.android.systemui.Gefingerpoken
33 import com.android.systemui.classifier.Classifier.NOTIFICATION_DISMISS
34 import com.android.systemui.media.controls.util.MediaUiEventLogger
35 import com.android.systemui.plugins.FalsingManager
36 import com.android.systemui.qs.PageIndicator
37 import com.android.systemui.res.R
38 import com.android.systemui.util.animation.TransitionLayout
39 import com.android.systemui.util.concurrency.DelayableExecutor
40 import com.android.wm.shell.shared.animation.PhysicsAnimator
41 import kotlin.math.sign
42 
43 private const val FLING_SLOP = 1000000
44 @VisibleForTesting const val DISMISS_DELAY = 100L
45 private const val SCROLL_DELAY = 100L
46 private const val RUBBERBAND_FACTOR = 0.2f
47 private const val SETTINGS_BUTTON_TRANSLATION_FRACTION = 0.3f
48 private const val TAG = "MediaCarouselScrollHandler"
49 
50 /**
51  * Default spring configuration to use for animations where stiffness and/or damping ratio were not
52  * provided, and a default spring was not set via [PhysicsAnimator.setDefaultSpringConfig].
53  */
54 private val translationConfig =
55     PhysicsAnimator.SpringConfig(SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_LOW_BOUNCY)
56 
57 /** A controller class for the media scrollview, responsible for touch handling */
58 class MediaCarouselScrollHandler(
59     private val scrollView: MediaScrollView,
60     private val pageIndicator: PageIndicator,
61     private val mainExecutor: DelayableExecutor,
62     val dismissCallback: () -> Unit,
63     private var translationChangedListener: () -> Unit,
64     private var seekBarUpdateListener: (visibleToUser: Boolean) -> Unit,
65     private val closeGuts: (immediate: Boolean) -> Unit,
66     private val falsingManager: FalsingManager,
67     private val logger: MediaUiEventLogger,
68 ) {
69     /** Trace state logger for media carousel visibility */
70     private val visibleStateLogger = TraceStateLogger("$TAG#visibleToUser")
71 
72     /** Is the view in RTL */
73     val isRtl: Boolean
74         get() = scrollView.isLayoutRtl
75 
76     /** Do we need falsing protection? */
77     var falsingProtectionNeeded: Boolean = false
78 
79     /** The width of the carousel */
80     private var carouselWidth: Int = 0
81 
82     /** The height of the carousel */
83     private var carouselHeight: Int = 0
84 
85     /** How much are we scrolled into the current media? */
86     private var cornerRadius: Int = 0
87 
88     /** The content where the players are added */
89     private var mediaContent: ViewGroup
90 
91     /** The gesture detector to detect touch gestures */
92     private val gestureDetector: GestureDetectorCompat
93 
94     /** The settings button view */
95     private lateinit var settingsButton: View
96 
97     /** What's the currently visible player index? */
98     var visibleMediaIndex: Int = 0
99         @VisibleForTesting set
100 
101     /** How much are we scrolled into the current media? */
102     private var scrollIntoCurrentMedia: Int = 0
103 
104     /** how much is the content translated in X */
105     var contentTranslation = 0.0f
106         private set(value) {
107             field = value
108             mediaContent.translationX = value
109             updateSettingsPresentation()
110             translationChangedListener.invoke()
111             updateClipToOutline()
112         }
113 
114     /** The width of a player including padding */
115     var playerWidthPlusPadding: Int = 0
116         set(value) {
117             field = value
118             // The player width has changed, let's update the scroll position to make sure
119             // it's still at the same place
120             var newRelativeScroll = visibleMediaIndex * playerWidthPlusPadding
121             if (scrollIntoCurrentMedia > playerWidthPlusPadding) {
122                 newRelativeScroll +=
123                     playerWidthPlusPadding - (scrollIntoCurrentMedia - playerWidthPlusPadding)
124             } else {
125                 newRelativeScroll += scrollIntoCurrentMedia
126             }
127             scrollView.relativeScrollX = newRelativeScroll
128         }
129 
130     /** Is scrolling disabled for the carousel */
131     var scrollingDisabled: Boolean = false
132 
133     /** Does the dismiss currently show the setting cog? */
134     var showsSettingsButton: Boolean = false
135 
136     /** A utility to detect gestures, used in the touch listener */
137     private val gestureListener =
138         object : GestureDetector.SimpleOnGestureListener() {
onFlingnull139             override fun onFling(
140                 eStart: MotionEvent?,
141                 eCurrent: MotionEvent,
142                 vX: Float,
143                 vY: Float,
144             ) = onFling(vX, vY)
145 
146             override fun onScroll(
147                 down: MotionEvent?,
148                 lastMotion: MotionEvent,
149                 distanceX: Float,
150                 distanceY: Float,
151             ) = onScroll(down!!, lastMotion, distanceX)
152 
153             override fun onDown(e: MotionEvent): Boolean {
154                 return false
155             }
156         }
157 
158     /** The touch listener for the scroll view */
159     @VisibleForTesting
160     val touchListener =
161         object : Gefingerpoken {
onTouchEventnull162             override fun onTouchEvent(motionEvent: MotionEvent?) = onTouch(motionEvent!!)
163 
164             override fun onInterceptTouchEvent(ev: MotionEvent?) = onInterceptTouch(ev!!)
165         }
166 
167     /** A listener that is invoked when the scrolling changes to update player visibilities */
168     private val scrollChangedListener =
169         object : View.OnScrollChangeListener {
170             override fun onScrollChange(
171                 v: View?,
172                 scrollX: Int,
173                 scrollY: Int,
174                 oldScrollX: Int,
175                 oldScrollY: Int,
176             ) {
177                 if (playerWidthPlusPadding == 0) {
178                     return
179                 }
180 
181                 val relativeScrollX = scrollView.relativeScrollX
182                 onMediaScrollingChanged(
183                     relativeScrollX / playerWidthPlusPadding,
184                     relativeScrollX % playerWidthPlusPadding,
185                 )
186             }
187         }
188 
189     /** Whether the media card is visible to user if any */
190     var visibleToUser: Boolean = false
191         set(value) {
192             if (field != value) {
193                 field = value
194                 seekBarUpdateListener.invoke(field)
195                 visibleStateLogger.log("$visibleToUser")
196             }
197         }
198 
199     /** Whether the quick setting is expanded or not */
200     var qsExpanded: Boolean = false
201 
202     init {
203         gestureDetector = GestureDetectorCompat(scrollView.context, gestureListener)
204         scrollView.touchListener = touchListener
205         scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER)
206         mediaContent = scrollView.contentContainer
207         scrollView.setOnScrollChangeListener(scrollChangedListener)
208         scrollView.outlineProvider =
209             object : ViewOutlineProvider() {
getOutlinenull210                 override fun getOutline(view: View?, outline: Outline?) {
211                     outline?.setRoundRect(
212                         0,
213                         0,
214                         carouselWidth,
215                         carouselHeight,
216                         cornerRadius.toFloat(),
217                     )
218                 }
219             }
220     }
221 
onSettingsButtonUpdatednull222     fun onSettingsButtonUpdated(button: View) {
223         settingsButton = button
224         // We don't have a context to resolve, lets use the settingsbuttons one since that is
225         // reinflated appropriately
226         cornerRadius =
227             settingsButton.resources.getDimensionPixelSize(
228                 Utils.getThemeAttr(settingsButton.context, android.R.attr.dialogCornerRadius)
229             )
230         updateSettingsPresentation()
231         scrollView.invalidateOutline()
232     }
233 
updateSettingsPresentationnull234     private fun updateSettingsPresentation() {
235         if (showsSettingsButton && settingsButton.width > 0) {
236             val settingsOffset =
237                 MathUtils.map(
238                     0.0f,
239                     getMaxTranslation().toFloat(),
240                     0.0f,
241                     1.0f,
242                     Math.abs(contentTranslation),
243                 )
244             val settingsTranslation =
245                 (1.0f - settingsOffset) *
246                     -settingsButton.width *
247                     SETTINGS_BUTTON_TRANSLATION_FRACTION
248             val newTranslationX =
249                 if (isRtl) {
250                     // In RTL, the 0-placement is on the right side of the view, not the left...
251                     if (contentTranslation > 0) {
252                         -(scrollView.width - settingsTranslation - settingsButton.width)
253                     } else {
254                         -settingsTranslation
255                     }
256                 } else {
257                     if (contentTranslation > 0) {
258                         settingsTranslation
259                     } else {
260                         scrollView.width - settingsTranslation - settingsButton.width
261                     }
262                 }
263             val rotation = (1.0f - settingsOffset) * 50
264             settingsButton.rotation = rotation * -Math.signum(contentTranslation)
265             val alpha = MathUtils.saturate(MathUtils.map(0.5f, 1.0f, 0.0f, 1.0f, settingsOffset))
266             settingsButton.alpha = alpha
267             settingsButton.visibility = if (alpha != 0.0f) View.VISIBLE else View.INVISIBLE
268             settingsButton.translationX = newTranslationX
269             settingsButton.translationY = (scrollView.height - settingsButton.height) / 2.0f
270         } else {
271             settingsButton.visibility = View.INVISIBLE
272         }
273     }
274 
onTouchnull275     private fun onTouch(motionEvent: MotionEvent): Boolean {
276         if (scrollingDisabled) {
277             return false
278         }
279 
280         val isUp = motionEvent.action == MotionEvent.ACTION_UP
281         if (gestureDetector.onTouchEvent(motionEvent)) {
282             if (isUp) {
283                 // If this is an up and we're flinging, we don't want to have this touch reach
284                 // the view, otherwise that would scroll, while we are trying to snap to the
285                 // new page. Let's dispatch a cancel instead.
286                 scrollView.cancelCurrentScroll()
287                 return true
288             } else {
289                 // Pass touches to the scrollView
290                 return false
291             }
292         }
293         if (motionEvent.action == MotionEvent.ACTION_MOVE) {
294             // cancel on going animation if there is any.
295             PhysicsAnimator.getInstance(this).cancel()
296         } else if (isUp || motionEvent.action == MotionEvent.ACTION_CANCEL) {
297             // It's an up and the fling didn't take it above
298             val relativePos = scrollView.relativeScrollX % playerWidthPlusPadding
299             val scrollXAmount: Int =
300                 if (relativePos > playerWidthPlusPadding / 2) {
301                     playerWidthPlusPadding - relativePos
302                 } else {
303                     -1 * relativePos
304                 }
305             if (scrollXAmount != 0) {
306                 val dx = if (isRtl) -scrollXAmount else scrollXAmount
307                 val newScrollX = scrollView.scrollX + dx
308                 // Delay the scrolling since scrollView calls springback which cancels
309                 // the animation again..
310                 mainExecutor.execute { scrollView.smoothScrollTo(newScrollX, scrollView.scrollY) }
311             }
312             val currentTranslation = scrollView.getContentTranslation()
313             if (currentTranslation != 0.0f) {
314                 // We started a Swipe but didn't end up with a fling. Let's either go to the
315                 // dismissed position or go back.
316                 val springBack =
317                     Math.abs(currentTranslation) < getMaxTranslation() / 2 || isFalseTouch()
318                 val newTranslation: Float
319                 if (springBack) {
320                     newTranslation = 0.0f
321                 } else {
322                     newTranslation = getMaxTranslation() * Math.signum(currentTranslation)
323                     if (!showsSettingsButton) {
324                         // Delay the dismiss a bit to avoid too much overlap. Waiting until the
325                         // animation has finished also feels a bit too slow here.
326                         mainExecutor.executeDelayed({ dismissCallback.invoke() }, DISMISS_DELAY)
327                     }
328                 }
329                 PhysicsAnimator.getInstance(this)
330                     .spring(
331                         CONTENT_TRANSLATION,
332                         newTranslation,
333                         startVelocity = 0.0f,
334                         config = translationConfig,
335                     )
336                     .start()
337                 scrollView.animationTargetX = newTranslation
338             }
339         }
340         // Always pass touches to the scrollView
341         return false
342     }
343 
isFalseTouchnull344     private fun isFalseTouch() =
345         falsingProtectionNeeded && falsingManager.isFalseTouch(NOTIFICATION_DISMISS)
346 
347     private fun getMaxTranslation() =
348         if (showsSettingsButton) {
349             settingsButton.width
350         } else {
351             playerWidthPlusPadding
352         }
353 
onInterceptTouchnull354     private fun onInterceptTouch(motionEvent: MotionEvent): Boolean {
355         return gestureDetector.onTouchEvent(motionEvent)
356     }
357 
onScrollnull358     fun onScroll(down: MotionEvent, lastMotion: MotionEvent, distanceX: Float): Boolean {
359         if (scrollingDisabled) {
360             return false
361         }
362 
363         val totalX = lastMotion.x - down.x
364         val currentTranslation = scrollView.getContentTranslation()
365         if (currentTranslation != 0.0f || !scrollView.canScrollHorizontally((-totalX).toInt())) {
366             var newTranslation = currentTranslation - distanceX
367             val absTranslation = Math.abs(newTranslation)
368             if (absTranslation > getMaxTranslation()) {
369                 // Rubberband all translation above the maximum
370                 if (Math.signum(distanceX) != Math.signum(currentTranslation)) {
371                     // The movement is in the same direction as our translation,
372                     // Let's rubberband it.
373                     if (Math.abs(currentTranslation) > getMaxTranslation()) {
374                         // we were already overshooting before. Let's add the distance
375                         // fully rubberbanded.
376                         newTranslation = currentTranslation - distanceX * RUBBERBAND_FACTOR
377                     } else {
378                         // We just crossed the boundary, let's rubberband it all
379                         newTranslation =
380                             Math.signum(newTranslation) *
381                                 (getMaxTranslation() +
382                                     (absTranslation - getMaxTranslation()) * RUBBERBAND_FACTOR)
383                     }
384                 } // Otherwise we don't have do do anything, and will remove the unrubberbanded
385                 // translation
386             }
387             if (
388                 Math.signum(newTranslation) != Math.signum(currentTranslation) &&
389                     currentTranslation != 0.0f
390             ) {
391                 // We crossed the 0.0 threshold of the translation. Let's see if we're allowed
392                 // to scroll into the new direction
393                 if (scrollView.canScrollHorizontally(-newTranslation.toInt())) {
394                     // We can actually scroll in the direction where we want to translate,
395                     // Let's make sure to stop at 0
396                     newTranslation = 0.0f
397                 }
398             }
399             val physicsAnimator = PhysicsAnimator.getInstance(this)
400             if (physicsAnimator.isRunning()) {
401                 physicsAnimator
402                     .spring(
403                         CONTENT_TRANSLATION,
404                         newTranslation,
405                         startVelocity = 0.0f,
406                         config = translationConfig,
407                     )
408                     .start()
409             } else {
410                 contentTranslation = newTranslation
411             }
412             scrollView.animationTargetX = newTranslation
413             return true
414         }
415         return false
416     }
417 
onFlingnull418     private fun onFling(vX: Float, vY: Float): Boolean {
419         if (scrollingDisabled) {
420             return false
421         }
422 
423         if (vX * vX < 0.5 * vY * vY) {
424             return false
425         }
426         if (vX * vX < FLING_SLOP) {
427             return false
428         }
429         val currentTranslation = scrollView.getContentTranslation()
430         if (currentTranslation != 0.0f) {
431             // We're translated and flung. Let's see if the fling is in the same direction
432             val newTranslation: Float
433             if (Math.signum(vX) != Math.signum(currentTranslation) || isFalseTouch()) {
434                 // The direction of the fling isn't the same as the translation, let's go to 0
435                 newTranslation = 0.0f
436             } else {
437                 newTranslation = getMaxTranslation() * Math.signum(currentTranslation)
438                 // Delay the dismiss a bit to avoid too much overlap. Waiting until the animation
439                 // has finished also feels a bit too slow here.
440                 if (!showsSettingsButton) {
441                     mainExecutor.executeDelayed({ dismissCallback.invoke() }, DISMISS_DELAY)
442                 }
443             }
444             PhysicsAnimator.getInstance(this)
445                 .spring(
446                     CONTENT_TRANSLATION,
447                     newTranslation,
448                     startVelocity = vX,
449                     config = translationConfig,
450                 )
451                 .start()
452             scrollView.animationTargetX = newTranslation
453         } else {
454             // We're flinging the player! Let's go either to the previous or to the next player
455             val pos = scrollView.relativeScrollX
456             val currentIndex = if (playerWidthPlusPadding > 0) pos / playerWidthPlusPadding else 0
457             val flungTowardEnd = if (isRtl) vX > 0 else vX < 0
458             var destIndex = if (flungTowardEnd) currentIndex + 1 else currentIndex
459             destIndex = Math.max(0, destIndex)
460             destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex)
461             val view = mediaContent.getChildAt(destIndex)
462             // We need to post this since we're dispatching a touch to the underlying view to cancel
463             // but canceling will actually abort the animation.
464             mainExecutor.execute { scrollView.smoothScrollTo(view.left, scrollView.scrollY) }
465         }
466         return true
467     }
468 
469     /** Reset the translation of the players when swiped */
resetTranslationnull470     fun resetTranslation(animate: Boolean = false) {
471         if (scrollView.getContentTranslation() != 0.0f) {
472             if (animate) {
473                 PhysicsAnimator.getInstance(this)
474                     .spring(CONTENT_TRANSLATION, 0.0f, config = translationConfig)
475                     .start()
476                 scrollView.animationTargetX = 0.0f
477             } else {
478                 PhysicsAnimator.getInstance(this).cancel()
479                 contentTranslation = 0.0f
480             }
481         }
482     }
483 
updateClipToOutlinenull484     private fun updateClipToOutline() {
485         val clip = contentTranslation != 0.0f || scrollIntoCurrentMedia != 0
486         scrollView.clipToOutline = clip
487     }
488 
onMediaScrollingChangednull489     private fun onMediaScrollingChanged(newIndex: Int, scrollInAmount: Int) {
490         val wasScrolledIn = scrollIntoCurrentMedia != 0
491         scrollIntoCurrentMedia = scrollInAmount
492         val nowScrolledIn = scrollIntoCurrentMedia != 0
493         if (newIndex != visibleMediaIndex || wasScrolledIn != nowScrolledIn) {
494             val oldIndex = visibleMediaIndex
495             visibleMediaIndex = newIndex
496             if (oldIndex != visibleMediaIndex && visibleToUser) {
497                 logger.logMediaCarouselPage(newIndex)
498             }
499             closeGuts(false)
500             updatePlayerVisibilities()
501         }
502         val relativeLocation =
503             visibleMediaIndex.toFloat() +
504                 if (playerWidthPlusPadding > 0) {
505                     scrollInAmount.toFloat() / playerWidthPlusPadding
506                 } else {
507                     0f
508                 }
509         // Fix the location, because PageIndicator does not handle RTL internally
510         val location =
511             if (isRtl) {
512                 mediaContent.childCount - relativeLocation - 1
513             } else {
514                 relativeLocation
515             }
516         pageIndicator.setLocation(location)
517         updateClipToOutline()
518     }
519 
520     /** Notified whenever the players or their order has changed */
onPlayersChangednull521     fun onPlayersChanged() {
522         updatePlayerVisibilities()
523         updateMediaPaddings()
524     }
525 
updateMediaPaddingsnull526     private fun updateMediaPaddings() {
527         val padding = scrollView.context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
528         val childCount = mediaContent.childCount
529         for (i in 0 until childCount) {
530             val mediaView = mediaContent.getChildAt(i)
531             val desiredPaddingEnd = if (i == childCount - 1) 0 else padding
532             val layoutParams = mediaView.layoutParams as ViewGroup.MarginLayoutParams
533             if (layoutParams.marginEnd != desiredPaddingEnd) {
534                 layoutParams.marginEnd = desiredPaddingEnd
535                 mediaView.layoutParams = layoutParams
536             }
537         }
538     }
539 
updatePlayerVisibilitiesnull540     private fun updatePlayerVisibilities() {
541         val scrolledIn = scrollIntoCurrentMedia != 0
542         for (i in 0 until mediaContent.childCount) {
543             val view = mediaContent.getChildAt(i)
544             val visible = (i == visibleMediaIndex) || ((i == (visibleMediaIndex + 1)) && scrolledIn)
545             view.visibility = if (visible) View.VISIBLE else View.INVISIBLE
546         }
547     }
548 
549     /**
550      * Notify that a player will be removed right away. This gives us the opporunity to look where
551      * it was and update our scroll position.
552      */
onPrePlayerRemovednull553     fun onPrePlayerRemoved(player: TransitionLayout?) {
554         val removedIndex = mediaContent.indexOfChild(player)
555         // If the removed index is less than the visibleMediaIndex, then we need to decrement it.
556         // RTL has no effect on this, because indices are always relative (start-to-end).
557         // Update the index 'manually' since we won't always get a call to onMediaScrollingChanged
558         val beforeActive = removedIndex <= visibleMediaIndex
559         if (beforeActive) {
560             visibleMediaIndex = Math.max(0, visibleMediaIndex - 1)
561         }
562         // If the removed media item is "left of" the active one (in an absolute sense), we need to
563         // scroll the view to keep that player in view.  This is because scroll position is always
564         // calculated from left to right.
565         // For RTL, we need to scroll if the visible media player is the last item.
566         val leftOfActive = if (isRtl && visibleMediaIndex != 0) !beforeActive else beforeActive
567         if (leftOfActive) {
568             scrollView.scrollX = Math.max(scrollView.scrollX - playerWidthPlusPadding, 0)
569         }
570     }
571 
572     /** Update the bounds of the carousel */
setCarouselBoundsnull573     fun setCarouselBounds(currentCarouselWidth: Int, currentCarouselHeight: Int) {
574         if (currentCarouselHeight != carouselHeight || currentCarouselWidth != carouselHeight) {
575             carouselWidth = currentCarouselWidth
576             carouselHeight = currentCarouselHeight
577             scrollView.invalidateOutline()
578         }
579     }
580 
581     /** Reset the MediaScrollView to the start. */
scrollToStartnull582     fun scrollToStart() {
583         scrollView.relativeScrollX = 0
584     }
585 
586     /**
587      * Smooth scroll to the destination player.
588      *
589      * @param sourceIndex optional source index to indicate where the scroll should begin.
590      * @param destIndex destination index to indicate where the scroll should end.
591      */
scrollToPlayernull592     fun scrollToPlayer(sourceIndex: Int = -1, destIndex: Int) {
593         if (scrollingDisabled) {
594             return
595         }
596         if (sourceIndex >= 0 && sourceIndex < mediaContent.childCount) {
597             scrollView.relativeScrollX = sourceIndex * playerWidthPlusPadding
598         }
599         val destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex)
600         val view = mediaContent.getChildAt(destIndex)
601         // We need to post this to wait for the active player becomes visible.
602         mainExecutor.executeDelayed(
603             { scrollView.smoothScrollTo(view.left, scrollView.scrollY) },
604             SCROLL_DELAY,
605         )
606     }
607 
608     /**
609      * Scrolls the media carousel by the number of players specified by [step]. If scrolling beyond
610      * the carousel's bounds:
611      * - If the carousel is not dismissible, the settings button is displayed.
612      * - If the carousel is dismissible, no action taken.
613      *
614      * @param step A positive number means next, and negative means previous.
615      */
scrollByStepnull616     fun scrollByStep(step: Int) {
617         if (scrollingDisabled) {
618             return
619         }
620         val destIndex = visibleMediaIndex + step
621         if (destIndex >= mediaContent.childCount || destIndex < 0) {
622             if (!showsSettingsButton) return
623             var translation = getMaxTranslation() * sign(-step.toFloat())
624             translation = if (isRtl) -translation else translation
625             PhysicsAnimator.getInstance(this)
626                 .spring(CONTENT_TRANSLATION, translation, config = translationConfig)
627                 .start()
628             scrollView.animationTargetX = translation
629         } else if (scrollView.getContentTranslation() != 0.0f) {
630             resetTranslation(true)
631         } else {
632             scrollToPlayer(destIndex = destIndex)
633         }
634     }
635 
636     companion object {
637         private val CONTENT_TRANSLATION =
638             object : FloatPropertyCompat<MediaCarouselScrollHandler>("contentTranslation") {
getValuenull639                 override fun getValue(handler: MediaCarouselScrollHandler): Float {
640                     return handler.contentTranslation
641                 }
642 
setValuenull643                 override fun setValue(handler: MediaCarouselScrollHandler, value: Float) {
644                     handler.contentTranslation = value
645                 }
646             }
647     }
648 }
649