• 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.viewmodel
18 
19 import android.icu.text.MeasureFormat
20 import android.icu.util.Measure
21 import android.icu.util.MeasureUnit
22 import android.media.MediaMetadata
23 import android.media.session.MediaController
24 import android.media.session.PlaybackState
25 import android.os.SystemClock
26 import android.os.Trace
27 import android.text.format.DateUtils
28 import android.view.GestureDetector
29 import android.view.MotionEvent
30 import android.view.View
31 import android.view.ViewConfiguration
32 import android.widget.SeekBar
33 import androidx.annotation.AnyThread
34 import androidx.annotation.VisibleForTesting
35 import androidx.annotation.WorkerThread
36 import androidx.core.view.GestureDetectorCompat
37 import androidx.lifecycle.LiveData
38 import androidx.lifecycle.MutableLiveData
39 import com.android.systemui.classifier.Classifier.MEDIA_SEEKBAR
40 import com.android.systemui.dagger.qualifiers.Background
41 import com.android.systemui.media.NotificationMediaManager
42 import com.android.systemui.plugins.FalsingManager
43 import com.android.systemui.util.concurrency.RepeatableExecutor
44 import java.util.Locale
45 import javax.inject.Inject
46 import kotlin.math.abs
47 
48 private const val POSITION_UPDATE_INTERVAL_MILLIS = 500L
49 private const val MIN_FLING_VELOCITY_SCALE_FACTOR = 10
50 private const val MIN_IN_SEC = 60
51 private const val HOUR_IN_SEC = MIN_IN_SEC * 60
52 
53 private const val TRACE_POSITION_NAME = "SeekBarPollingPosition"
54 
PlaybackStatenull55 private fun PlaybackState.isInMotion(): Boolean {
56     return this.state == PlaybackState.STATE_PLAYING ||
57         this.state == PlaybackState.STATE_FAST_FORWARDING ||
58         this.state == PlaybackState.STATE_REWINDING
59 }
60 
61 /**
62  * Gets the playback position while accounting for the time since the [PlaybackState] was last
63  * retrieved.
64  *
65  * This method closely follows the implementation of
66  * [MediaSessionRecord#getStateWithUpdatedPosition].
67  */
computePositionnull68 private fun PlaybackState.computePosition(duration: Long): Long {
69     var currentPosition = this.position
70     if (this.isInMotion()) {
71         val updateTime = this.getLastPositionUpdateTime()
72         val currentTime = SystemClock.elapsedRealtime()
73         if (updateTime > 0) {
74             var position =
75                 (this.playbackSpeed * (currentTime - updateTime)).toLong() + this.getPosition()
76             if (duration >= 0 && position > duration) {
77                 position = duration.toLong()
78             } else if (position < 0) {
79                 position = 0
80             }
81             currentPosition = position
82         }
83     }
84     return currentPosition
85 }
86 
87 /** ViewModel for seek bar in QS media player. */
88 class SeekBarViewModel
89 @Inject
90 constructor(
91     @Background private val bgExecutor: RepeatableExecutor,
92     private val falsingManager: FalsingManager,
93 ) {
94     private var _data =
95         Progress(
96             enabled = false,
97             seekAvailable = false,
98             playing = false,
99             scrubbing = false,
100             elapsedTime = null,
101             duration = 0,
102             listening = false,
103         )
104         set(value) {
105             val enabledChanged = value.enabled != field.enabled
106             field = value
107             if (enabledChanged) {
108                 enabledChangeListener?.onEnabledChanged(value.enabled)
109             }
110             _progress.postValue(value)
111 
<lambda>null112             bgExecutor.execute {
113                 val durationDescription = formatTimeContentDescription(value.duration)
114                 val elapsedDescription =
115                     value.elapsedTime?.let { formatTimeContentDescription(it) } ?: ""
116                 contentDescriptionListener?.onContentDescriptionChanged(
117                     elapsedDescription,
118                     durationDescription,
119                 )
120             }
121         }
122 
<lambda>null123     private val _progress = MutableLiveData<Progress>().apply { postValue(_data) }
124     val progress: LiveData<Progress>
125         get() = _progress
126 
127     private var controller: MediaController? = null
128         set(value) {
129             if (field?.sessionToken != value?.sessionToken) {
130                 field?.unregisterCallback(callback)
131                 value?.registerCallback(callback)
132                 field = value
133             }
134         }
135 
136     private var playbackState: PlaybackState? = null
137     private var callback =
138         object : MediaController.Callback() {
onPlaybackStateChangednull139             override fun onPlaybackStateChanged(state: PlaybackState?) {
140                 playbackState = state
141                 if (playbackState == null || PlaybackState.STATE_NONE.equals(playbackState)) {
142                     clearController()
143                 } else {
144                     checkIfPollingNeeded()
145                 }
146             }
147 
onSessionDestroyednull148             override fun onSessionDestroyed() {
149                 clearController()
150             }
151 
onMetadataChangednull152             override fun onMetadataChanged(metadata: MediaMetadata?) {
153                 val (enabled, duration) = getEnabledStateAndDuration(metadata)
154                 if (_data.duration != duration) {
155                     _data = _data.copy(enabled = enabled, duration = duration)
156                 }
157             }
158         }
159     private var cancel: Runnable? = null
160 
161     /** Indicates if the seek interaction is considered a false guesture. */
162     private var isFalseSeek = false
163 
164     /** Listening state (QS open or closed) is used to control polling of progress. */
165     var listening = true
166         set(value) =
<lambda>null167             bgExecutor.execute {
168                 if (field != value) {
169                     field = value
170                     checkIfPollingNeeded()
171                     _data = _data.copy(listening = value)
172                 }
173             }
174 
175     private var scrubbingChangeListener: ScrubbingChangeListener? = null
176     private var enabledChangeListener: EnabledChangeListener? = null
177     private var contentDescriptionListener: ContentDescriptionListener? = null
178 
179     /** Set to true when the user is touching the seek bar to change the position. */
180     private var scrubbing = false
181         set(value) {
182             if (field != value) {
183                 field = value
184                 checkIfPollingNeeded()
185                 scrubbingChangeListener?.onScrubbingChanged(value)
186                 _data = _data.copy(scrubbing = value)
187             }
188         }
189 
190     lateinit var logSeek: () -> Unit
191 
192     /** Event indicating that the user has started interacting with the seek bar. */
193     @AnyThread
onSeekStartingnull194     fun onSeekStarting() =
195         bgExecutor.execute {
196             scrubbing = true
197             isFalseSeek = false
198         }
199 
200     /**
201      * Event indicating that the user has moved the seek bar.
202      *
203      * @param position Current location in the track.
204      */
205     @AnyThread
onSeekProgressnull206     fun onSeekProgress(position: Long) =
207         bgExecutor.execute {
208             if (scrubbing) {
209                 // The user hasn't yet finished their touch gesture, so only update the data for
210                 // visual
211                 // feedback and don't update [controller] yet.
212                 _data = _data.copy(elapsedTime = position.toInt())
213             } else {
214                 // The seek progress came from an a11y action and we should immediately update to
215                 // the
216                 // new position. (a11y actions to change the seekbar position don't trigger
217                 // SeekBar.OnSeekBarChangeListener.onStartTrackingTouch or onStopTrackingTouch.)
218                 onSeek(position)
219             }
220         }
221 
222     /** Event indicating that the seek interaction is a false gesture and it should be ignored. */
223     @AnyThread
onSeekFalsenull224     fun onSeekFalse() =
225         bgExecutor.execute {
226             if (scrubbing) {
227                 isFalseSeek = true
228             }
229         }
230 
231     /**
232      * Handle request to change the current position in the media track.
233      *
234      * @param position Place to seek to in the track.
235      */
236     @AnyThread
onSeeknull237     fun onSeek(position: Long) =
238         bgExecutor.execute {
239             if (isFalseSeek) {
240                 scrubbing = false
241                 checkPlaybackPosition()
242             } else {
243                 logSeek()
244                 controller?.transportControls?.seekTo(position)
245                 // Invalidate the cached playbackState to avoid the thumb jumping back to the
246                 // previous
247                 // position.
248                 playbackState = null
249                 scrubbing = false
250             }
251         }
252 
253     /**
254      * Updates media information.
255      *
256      * This function makes a binder call, so it must happen on a worker thread.
257      *
258      * @param mediaController controller for media session
259      */
260     @WorkerThread
updateControllernull261     fun updateController(mediaController: MediaController?) {
262         controller = mediaController
263         playbackState = controller?.playbackState
264         val (enabled, duration) = getEnabledStateAndDuration(controller?.metadata)
265         val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L
266         val position = playbackState?.position?.toInt()
267         val playing =
268             NotificationMediaManager.isPlayingState(
269                 playbackState?.state ?: PlaybackState.STATE_NONE
270             )
271         _data = Progress(enabled, seekAvailable, playing, scrubbing, position, duration, listening)
272         // No need to update since we just set the progress info
273         checkIfPollingNeeded(requireUpdate = false)
274     }
275 
276     /**
277      * Set the progress to a fixed percentage value that cannot be changed by the user.
278      *
279      * @param percent value between 0 and 1
280      */
updateStaticProgressnull281     fun updateStaticProgress(percent: Double) {
282         val position = (percent * 100).toInt()
283         _data =
284             Progress(
285                 enabled = true,
286                 seekAvailable = false,
287                 playing = false,
288                 scrubbing = false,
289                 elapsedTime = position,
290                 duration = 100,
291                 listening = false,
292             )
293     }
294 
295     /**
296      * Puts the seek bar into a resumption state.
297      *
298      * This should be called when the media session behind the controller has been destroyed.
299      */
300     @AnyThread
clearControllernull301     fun clearController() =
302         bgExecutor.execute {
303             controller = null
304             playbackState = null
305             cancel?.run()
306             cancel = null
307             _data = _data.copy(enabled = false)
308         }
309 
310     /** Call to clean up any resources. */
311     @AnyThread
onDestroynull312     fun onDestroy() =
313         bgExecutor.execute {
314             controller = null
315             playbackState = null
316             cancel?.run()
317             cancel = null
318             scrubbingChangeListener = null
319             enabledChangeListener = null
320         }
321 
322     @WorkerThread
checkPlaybackPositionnull323     private fun checkPlaybackPosition() {
324         val duration = _data.duration ?: -1
325         val currentPosition = playbackState?.computePosition(duration.toLong())?.toInt()
326         if (currentPosition != null && _data.elapsedTime != currentPosition) {
327             _data = _data.copy(elapsedTime = currentPosition)
328         }
329     }
330 
331     /**
332      * Begin polling if needed given the current seekbar state
333      *
334      * @param requireUpdate If true, update the playback position without beginning polling
335      */
336     @WorkerThread
checkIfPollingNeedednull337     private fun checkIfPollingNeeded(requireUpdate: Boolean = true) {
338         val needed = listening && !scrubbing && playbackState?.isInMotion() ?: false
339         val traceCookie = controller?.sessionToken.hashCode()
340         if (needed) {
341             if (cancel == null) {
342                 Trace.beginAsyncSection(TRACE_POSITION_NAME, traceCookie)
343                 val cancelPolling =
344                     bgExecutor.executeRepeatedly(
345                         this::checkPlaybackPosition,
346                         0L,
347                         POSITION_UPDATE_INTERVAL_MILLIS,
348                     )
349                 cancel = Runnable {
350                     cancelPolling.run()
351                     Trace.endAsyncSection(TRACE_POSITION_NAME, traceCookie)
352                 }
353             }
354         } else if (requireUpdate) {
355             checkPlaybackPosition()
356             cancel?.run()
357             cancel = null
358         }
359     }
360 
361     /** Gets a listener to attach to the seek bar to handle seeking. */
362     val seekBarListener: SeekBar.OnSeekBarChangeListener
363         get() {
364             return SeekBarChangeListener(this, falsingManager)
365         }
366 
367     /** first and last motion events of seekbar grab. */
368     @VisibleForTesting var firstMotionEvent: MotionEvent? = null
369     @VisibleForTesting var lastMotionEvent: MotionEvent? = null
370 
371     /** Attach touch handlers to the seek bar view. */
attachTouchHandlersnull372     fun attachTouchHandlers(bar: SeekBar) {
373         bar.setOnSeekBarChangeListener(seekBarListener)
374         bar.setOnTouchListener(SeekBarTouchListener(this, bar))
375     }
376 
setScrubbingChangeListenernull377     fun setScrubbingChangeListener(listener: ScrubbingChangeListener) {
378         scrubbingChangeListener = listener
379     }
380 
removeScrubbingChangeListenernull381     fun removeScrubbingChangeListener(listener: ScrubbingChangeListener) {
382         if (listener == scrubbingChangeListener) {
383             scrubbingChangeListener = null
384         }
385     }
386 
setEnabledChangeListenernull387     fun setEnabledChangeListener(listener: EnabledChangeListener) {
388         enabledChangeListener = listener
389     }
390 
removeEnabledChangeListenernull391     fun removeEnabledChangeListener(listener: EnabledChangeListener) {
392         if (listener == enabledChangeListener) {
393             enabledChangeListener = null
394         }
395     }
396 
setContentDescriptionListenernull397     fun setContentDescriptionListener(listener: ContentDescriptionListener) {
398         contentDescriptionListener = listener
399     }
400 
removeContentDescriptionListenernull401     fun removeContentDescriptionListener(listener: ContentDescriptionListener) {
402         if (listener == contentDescriptionListener) {
403             contentDescriptionListener = null
404         }
405     }
406 
407     /** returns a pair of whether seekbar is enabled and the duration of media. */
getEnabledStateAndDurationnull408     private fun getEnabledStateAndDuration(metadata: MediaMetadata?): Pair<Boolean, Int> {
409         val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt() ?: 0
410         val enabled =
411             !(playbackState == null ||
412                 playbackState?.state == PlaybackState.STATE_NONE ||
413                 (duration <= 0))
414         return Pair(enabled, duration)
415     }
416 
417     /**
418      * This method specifies if user made a bad seekbar grab or not. If the vertical distance from
419      * first touch on seekbar is more than the horizontal distance, this means that the seekbar grab
420      * is more vertical and should be rejected. Seekbar accepts horizontal grabs only.
421      *
422      * Single tap has the same first and last motion event, it is counted as a valid grab.
423      *
424      * @return whether the touch on seekbar is valid.
425      */
isValidSeekbarGrabnull426     private fun isValidSeekbarGrab(): Boolean {
427         if (firstMotionEvent == null || lastMotionEvent == null) {
428             return true
429         }
430         return abs(firstMotionEvent!!.x - lastMotionEvent!!.x) >=
431             abs(firstMotionEvent!!.y - lastMotionEvent!!.y)
432     }
433 
434     /**
435      * Returns a time string suitable for content description, e.g. "12 minutes 34 seconds"
436      *
437      * Follows same logic as Chronometer#formatDuration
438      */
formatTimeContentDescriptionnull439     private fun formatTimeContentDescription(milliseconds: Int): CharSequence {
440         var seconds = milliseconds / DateUtils.SECOND_IN_MILLIS
441 
442         val hours =
443             if (seconds >= HOUR_IN_SEC) {
444                 seconds / HOUR_IN_SEC
445             } else {
446                 0
447             }
448         seconds -= hours * HOUR_IN_SEC
449 
450         val minutes =
451             if (seconds >= MIN_IN_SEC) {
452                 seconds / MIN_IN_SEC
453             } else {
454                 0
455             }
456         seconds -= minutes * MIN_IN_SEC
457 
458         val measures = arrayListOf<Measure>()
459         if (hours > 0) {
460             measures.add(Measure(hours, MeasureUnit.HOUR))
461         }
462         if (minutes > 0) {
463             measures.add(Measure(minutes, MeasureUnit.MINUTE))
464         }
465         measures.add(Measure(seconds, MeasureUnit.SECOND))
466 
467         return MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE)
468             .formatMeasures(*measures.toTypedArray())
469     }
470 
471     /** Listener interface to be notified when the user starts or stops scrubbing. */
472     interface ScrubbingChangeListener {
onScrubbingChangednull473         fun onScrubbingChanged(scrubbing: Boolean)
474     }
475 
476     /** Listener interface to be notified when the seekbar's enabled status changes. */
477     interface EnabledChangeListener {
478         fun onEnabledChanged(enabled: Boolean)
479     }
480 
481     interface ContentDescriptionListener {
onContentDescriptionChangednull482         fun onContentDescriptionChanged(
483             elapsedTimeDescription: CharSequence,
484             durationDescription: CharSequence,
485         )
486     }
487 
488     private class SeekBarChangeListener(
489         val viewModel: SeekBarViewModel,
490         val falsingManager: FalsingManager,
491     ) : SeekBar.OnSeekBarChangeListener {
492         override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
493             if (fromUser) {
494                 viewModel.onSeekProgress(progress.toLong())
495             }
496         }
497 
498         override fun onStartTrackingTouch(bar: SeekBar) {
499             viewModel.onSeekStarting()
500         }
501 
502         override fun onStopTrackingTouch(bar: SeekBar) {
503             if (!viewModel.isValidSeekbarGrab() || falsingManager.isFalseTouch(MEDIA_SEEKBAR)) {
504                 viewModel.onSeekFalse()
505             }
506             viewModel.onSeek(bar.progress.toLong())
507         }
508     }
509 
510     /**
511      * Responsible for intercepting touch events before they reach the seek bar.
512      *
513      * This reduces the gestures seen by the seek bar so that users don't accidentially seek when
514      * they intend to scroll the carousel.
515      */
516     private class SeekBarTouchListener(
517         private val viewModel: SeekBarViewModel,
518         private val bar: SeekBar,
519     ) : View.OnTouchListener, GestureDetector.OnGestureListener {
520 
521         // Gesture detector helps decide which touch events to intercept.
522         private val detector = GestureDetectorCompat(bar.context, this)
523         // Velocity threshold used to decide when a fling is considered a false gesture.
524         private val flingVelocity: Int =
<lambda>null525             ViewConfiguration.get(bar.context).run {
526                 getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_SCALE_FACTOR
527             }
528         // Indicates if the gesture should go to the seek bar or if it should be intercepted.
529         private var shouldGoToSeekBar = false
530 
531         /**
532          * Decide which touch events to intercept before they reach the seek bar.
533          *
534          * Based on the gesture detected, we decide whether we want the event to reach the seek bar.
535          * If we want the seek bar to see the event, then we return false so that the event isn't
536          * handled here and it will be passed along. If, however, we don't want the seek bar to see
537          * the event, then return true so that the event is handled here.
538          *
539          * When the seek bar is contained in the carousel, the carousel still has the ability to
540          * intercept the touch event. So, even though we may handle the event here, the carousel can
541          * still intercept the event. This way, gestures that we consider falses on the seek bar can
542          * still be used by the carousel for paging.
543          *
544          * Returns true for events that we don't want dispatched to the seek bar.
545          */
onTouchnull546         override fun onTouch(view: View, event: MotionEvent): Boolean {
547             if (view != bar) {
548                 return false
549             }
550             detector.onTouchEvent(event)
551             // Store the last motion event done on seekbar.
552             viewModel.lastMotionEvent = event.copy()
553             return !shouldGoToSeekBar
554         }
555 
556         /**
557          * Handle down events that press down on the thumb.
558          *
559          * On the down action, determine a target box around the thumb to know when a scroll gesture
560          * starts by clicking on the thumb. The target box will be used by subsequent onScroll
561          * events.
562          *
563          * Returns true when the down event hits within the target box of the thumb.
564          */
onDownnull565         override fun onDown(event: MotionEvent): Boolean {
566             val padL = bar.paddingLeft
567             val padR = bar.paddingRight
568             // Compute the X location of the thumb as a function of the seek bar progress.
569             // TODO: account for thumb offset
570             val progress = bar.getProgress()
571             val range = bar.max - bar.min
572             val widthFraction =
573                 if (range > 0) {
574                     (progress - bar.min).toDouble() / range
575                 } else {
576                     0.0
577                 }
578             val availableWidth = bar.width - padL - padR
579             val thumbX =
580                 if (bar.isLayoutRtl()) {
581                     padL + availableWidth * (1 - widthFraction)
582                 } else {
583                     padL + availableWidth * widthFraction
584                 }
585             // Set the min, max boundaries of the thumb box.
586             // I'm cheating by using the height of the seek bar as the width of the box.
587             val halfHeight: Int = bar.height / 2
588             val targetBoxMinX = (Math.round(thumbX) - halfHeight).toInt()
589             val targetBoxMaxX = (Math.round(thumbX) + halfHeight).toInt()
590             // If the x position of the down event is within the box, then request that the parent
591             // not intercept the event.
592             val x = Math.round(event.x)
593             shouldGoToSeekBar = x >= targetBoxMinX && x <= targetBoxMaxX
594             if (shouldGoToSeekBar) {
595                 bar.parent?.requestDisallowInterceptTouchEvent(true)
596             }
597             // Store the first motion event done on seekbar.
598             viewModel.firstMotionEvent = event.copy()
599             return shouldGoToSeekBar
600         }
601 
602         /**
603          * Always handle single tap up.
604          *
605          * This enables the user to single tap anywhere on the seek bar to seek to that position.
606          */
onSingleTapUpnull607         override fun onSingleTapUp(event: MotionEvent): Boolean {
608             shouldGoToSeekBar = true
609             return true
610         }
611 
612         /**
613          * Handle scroll events when the down event is on the thumb.
614          *
615          * Returns true when the down event of the scroll hits within the target box of the thumb.
616          */
onScrollnull617         override fun onScroll(
618             eventStart: MotionEvent?,
619             event: MotionEvent,
620             distanceX: Float,
621             distanceY: Float,
622         ): Boolean {
623             return shouldGoToSeekBar
624         }
625 
626         /**
627          * Handle fling events when the down event is on the thumb.
628          *
629          * Gestures that include a fling are considered a false gesture on the seek bar.
630          */
onFlingnull631         override fun onFling(
632             eventStart: MotionEvent?,
633             event: MotionEvent,
634             velocityX: Float,
635             velocityY: Float,
636         ): Boolean {
637             if (Math.abs(velocityX) > flingVelocity || Math.abs(velocityY) > flingVelocity) {
638                 viewModel.onSeekFalse()
639             }
640             return shouldGoToSeekBar
641         }
642 
onShowPressnull643         override fun onShowPress(event: MotionEvent) {}
644 
onLongPressnull645         override fun onLongPress(event: MotionEvent) {}
646     }
647 
648     /** State seen by seek bar UI. */
649     data class Progress(
650         val enabled: Boolean,
651         val seekAvailable: Boolean,
652         /** whether playback state is not paused or connecting */
653         val playing: Boolean,
654         val scrubbing: Boolean,
655         val elapsedTime: Int?,
656         val duration: Int,
657         /** whether seekBar is listening to progress updates */
658         val listening: Boolean,
659     )
660 }
661