• 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.media.MediaMetadata
20 import android.media.session.MediaController
21 import android.media.session.PlaybackState
22 import android.os.SystemClock
23 import android.os.Trace
24 import android.view.GestureDetector
25 import android.view.MotionEvent
26 import android.view.View
27 import android.view.ViewConfiguration
28 import android.widget.SeekBar
29 import androidx.annotation.AnyThread
30 import androidx.annotation.VisibleForTesting
31 import androidx.annotation.WorkerThread
32 import androidx.core.view.GestureDetectorCompat
33 import androidx.lifecycle.LiveData
34 import androidx.lifecycle.MutableLiveData
35 import com.android.systemui.classifier.Classifier.MEDIA_SEEKBAR
36 import com.android.systemui.dagger.qualifiers.Background
37 import com.android.systemui.plugins.FalsingManager
38 import com.android.systemui.statusbar.NotificationMediaManager
39 import com.android.systemui.util.concurrency.RepeatableExecutor
40 import javax.inject.Inject
41 import kotlin.math.abs
42 
43 private const val POSITION_UPDATE_INTERVAL_MILLIS = 100L
44 private const val MIN_FLING_VELOCITY_SCALE_FACTOR = 10
45 
46 private const val TRACE_POSITION_NAME = "SeekBarPollingPosition"
47 
PlaybackStatenull48 private fun PlaybackState.isInMotion(): Boolean {
49     return this.state == PlaybackState.STATE_PLAYING ||
50         this.state == PlaybackState.STATE_FAST_FORWARDING ||
51         this.state == PlaybackState.STATE_REWINDING
52 }
53 
54 /**
55  * Gets the playback position while accounting for the time since the [PlaybackState] was last
56  * retrieved.
57  *
58  * This method closely follows the implementation of
59  * [MediaSessionRecord#getStateWithUpdatedPosition].
60  */
computePositionnull61 private fun PlaybackState.computePosition(duration: Long): Long {
62     var currentPosition = this.position
63     if (this.isInMotion()) {
64         val updateTime = this.getLastPositionUpdateTime()
65         val currentTime = SystemClock.elapsedRealtime()
66         if (updateTime > 0) {
67             var position =
68                 (this.playbackSpeed * (currentTime - updateTime)).toLong() + this.getPosition()
69             if (duration >= 0 && position > duration) {
70                 position = duration.toLong()
71             } else if (position < 0) {
72                 position = 0
73             }
74             currentPosition = position
75         }
76     }
77     return currentPosition
78 }
79 
80 /** ViewModel for seek bar in QS media player. */
81 class SeekBarViewModel
82 @Inject
83 constructor(
84     @Background private val bgExecutor: RepeatableExecutor,
85     private val falsingManager: FalsingManager,
86 ) {
87     private var _data =
88         Progress(
89             enabled = false,
90             seekAvailable = false,
91             playing = false,
92             scrubbing = false,
93             elapsedTime = null,
94             duration = 0,
95             listening = false
96         )
97         set(value) {
98             val enabledChanged = value.enabled != field.enabled
99             field = value
100             if (enabledChanged) {
101                 enabledChangeListener?.onEnabledChanged(value.enabled)
102             }
103             _progress.postValue(value)
104         }
<lambda>null105     private val _progress = MutableLiveData<Progress>().apply { postValue(_data) }
106     val progress: LiveData<Progress>
107         get() = _progress
108     private var controller: MediaController? = null
109         set(value) {
110             if (field?.sessionToken != value?.sessionToken) {
111                 field?.unregisterCallback(callback)
112                 value?.registerCallback(callback)
113                 field = value
114             }
115         }
116     private var playbackState: PlaybackState? = null
117     private var callback =
118         object : MediaController.Callback() {
onPlaybackStateChangednull119             override fun onPlaybackStateChanged(state: PlaybackState?) {
120                 playbackState = state
121                 if (playbackState == null || PlaybackState.STATE_NONE.equals(playbackState)) {
122                     clearController()
123                 } else {
124                     checkIfPollingNeeded()
125                 }
126             }
127 
onSessionDestroyednull128             override fun onSessionDestroyed() {
129                 clearController()
130             }
131         }
132     private var cancel: Runnable? = null
133 
134     /** Indicates if the seek interaction is considered a false guesture. */
135     private var isFalseSeek = false
136 
137     /** Listening state (QS open or closed) is used to control polling of progress. */
138     var listening = true
139         set(value) =
<lambda>null140             bgExecutor.execute {
141                 if (field != value) {
142                     field = value
143                     checkIfPollingNeeded()
144                     _data = _data.copy(listening = value)
145                 }
146             }
147 
148     private var scrubbingChangeListener: ScrubbingChangeListener? = null
149     private var enabledChangeListener: EnabledChangeListener? = null
150 
151     /** Set to true when the user is touching the seek bar to change the position. */
152     private var scrubbing = false
153         set(value) {
154             if (field != value) {
155                 field = value
156                 checkIfPollingNeeded()
157                 scrubbingChangeListener?.onScrubbingChanged(value)
158                 _data = _data.copy(scrubbing = value)
159             }
160         }
161 
162     lateinit var logSeek: () -> Unit
163 
164     /** Event indicating that the user has started interacting with the seek bar. */
165     @AnyThread
onSeekStartingnull166     fun onSeekStarting() =
167         bgExecutor.execute {
168             scrubbing = true
169             isFalseSeek = false
170         }
171 
172     /**
173      * Event indicating that the user has moved the seek bar.
174      *
175      * @param position Current location in the track.
176      */
177     @AnyThread
onSeekProgressnull178     fun onSeekProgress(position: Long) =
179         bgExecutor.execute {
180             if (scrubbing) {
181                 // The user hasn't yet finished their touch gesture, so only update the data for
182                 // visual
183                 // feedback and don't update [controller] yet.
184                 _data = _data.copy(elapsedTime = position.toInt())
185             } else {
186                 // The seek progress came from an a11y action and we should immediately update to
187                 // the
188                 // new position. (a11y actions to change the seekbar position don't trigger
189                 // SeekBar.OnSeekBarChangeListener.onStartTrackingTouch or onStopTrackingTouch.)
190                 onSeek(position)
191             }
192         }
193 
194     /** Event indicating that the seek interaction is a false gesture and it should be ignored. */
195     @AnyThread
onSeekFalsenull196     fun onSeekFalse() =
197         bgExecutor.execute {
198             if (scrubbing) {
199                 isFalseSeek = true
200             }
201         }
202 
203     /**
204      * Handle request to change the current position in the media track.
205      *
206      * @param position Place to seek to in the track.
207      */
208     @AnyThread
onSeeknull209     fun onSeek(position: Long) =
210         bgExecutor.execute {
211             if (isFalseSeek) {
212                 scrubbing = false
213                 checkPlaybackPosition()
214             } else {
215                 logSeek()
216                 controller?.transportControls?.seekTo(position)
217                 // Invalidate the cached playbackState to avoid the thumb jumping back to the
218                 // previous
219                 // position.
220                 playbackState = null
221                 scrubbing = false
222             }
223         }
224 
225     /**
226      * Updates media information.
227      *
228      * This function makes a binder call, so it must happen on a worker thread.
229      *
230      * @param mediaController controller for media session
231      */
232     @WorkerThread
updateControllernull233     fun updateController(mediaController: MediaController?) {
234         controller = mediaController
235         playbackState = controller?.playbackState
236         val mediaMetadata = controller?.metadata
237         val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L
238         val position = playbackState?.position?.toInt()
239         val duration = mediaMetadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt() ?: 0
240         val playing =
241             NotificationMediaManager.isPlayingState(
242                 playbackState?.state ?: PlaybackState.STATE_NONE
243             )
244         val enabled =
245             if (
246                 playbackState == null ||
247                     playbackState?.getState() == PlaybackState.STATE_NONE ||
248                     (duration <= 0)
249             )
250                 false
251             else true
252         _data = Progress(enabled, seekAvailable, playing, scrubbing, position, duration, listening)
253         checkIfPollingNeeded()
254     }
255 
256     /**
257      * Set the progress to a fixed percentage value that cannot be changed by the user.
258      *
259      * @param percent value between 0 and 1
260      */
updateStaticProgressnull261     fun updateStaticProgress(percent: Double) {
262         val position = (percent * 100).toInt()
263         _data =
264             Progress(
265                 enabled = true,
266                 seekAvailable = false,
267                 playing = false,
268                 scrubbing = false,
269                 elapsedTime = position,
270                 duration = 100,
271                 listening = false,
272             )
273     }
274 
275     /**
276      * Puts the seek bar into a resumption state.
277      *
278      * This should be called when the media session behind the controller has been destroyed.
279      */
280     @AnyThread
clearControllernull281     fun clearController() =
282         bgExecutor.execute {
283             controller = null
284             playbackState = null
285             cancel?.run()
286             cancel = null
287             _data = _data.copy(enabled = false)
288         }
289 
290     /** Call to clean up any resources. */
291     @AnyThread
onDestroynull292     fun onDestroy() =
293         bgExecutor.execute {
294             controller = null
295             playbackState = null
296             cancel?.run()
297             cancel = null
298             scrubbingChangeListener = null
299             enabledChangeListener = null
300         }
301 
302     @WorkerThread
checkPlaybackPositionnull303     private fun checkPlaybackPosition() {
304         val duration = _data.duration ?: -1
305         val currentPosition = playbackState?.computePosition(duration.toLong())?.toInt()
306         if (currentPosition != null && _data.elapsedTime != currentPosition) {
307             _data = _data.copy(elapsedTime = currentPosition)
308         }
309     }
310 
311     @WorkerThread
checkIfPollingNeedednull312     private fun checkIfPollingNeeded() {
313         val needed = listening && !scrubbing && playbackState?.isInMotion() ?: false
314         val traceCookie = controller?.sessionToken.hashCode()
315         if (needed) {
316             if (cancel == null) {
317                 Trace.beginAsyncSection(TRACE_POSITION_NAME, traceCookie)
318                 val cancelPolling =
319                     bgExecutor.executeRepeatedly(
320                         this::checkPlaybackPosition,
321                         0L,
322                         POSITION_UPDATE_INTERVAL_MILLIS
323                     )
324                 cancel = Runnable {
325                     cancelPolling.run()
326                     Trace.endAsyncSection(TRACE_POSITION_NAME, traceCookie)
327                 }
328             }
329         } else {
330             cancel?.run()
331             cancel = null
332         }
333     }
334 
335     /** Gets a listener to attach to the seek bar to handle seeking. */
336     val seekBarListener: SeekBar.OnSeekBarChangeListener
337         get() {
338             return SeekBarChangeListener(this, falsingManager)
339         }
340 
341     /** first and last motion events of seekbar grab. */
342     @VisibleForTesting var firstMotionEvent: MotionEvent? = null
343     @VisibleForTesting var lastMotionEvent: MotionEvent? = null
344 
345     /** Attach touch handlers to the seek bar view. */
attachTouchHandlersnull346     fun attachTouchHandlers(bar: SeekBar) {
347         bar.setOnSeekBarChangeListener(seekBarListener)
348         bar.setOnTouchListener(SeekBarTouchListener(this, bar))
349     }
350 
setScrubbingChangeListenernull351     fun setScrubbingChangeListener(listener: ScrubbingChangeListener) {
352         scrubbingChangeListener = listener
353     }
354 
removeScrubbingChangeListenernull355     fun removeScrubbingChangeListener(listener: ScrubbingChangeListener) {
356         if (listener == scrubbingChangeListener) {
357             scrubbingChangeListener = null
358         }
359     }
360 
setEnabledChangeListenernull361     fun setEnabledChangeListener(listener: EnabledChangeListener) {
362         enabledChangeListener = listener
363     }
364 
removeEnabledChangeListenernull365     fun removeEnabledChangeListener(listener: EnabledChangeListener) {
366         if (listener == enabledChangeListener) {
367             enabledChangeListener = null
368         }
369     }
370 
371     /**
372      * This method specifies if user made a bad seekbar grab or not. If the vertical distance from
373      * first touch on seekbar is more than the horizontal distance, this means that the seekbar grab
374      * is more vertical and should be rejected. Seekbar accepts horizontal grabs only.
375      *
376      * Single tap has the same first and last motion event, it is counted as a valid grab.
377      *
378      * @return whether the touch on seekbar is valid.
379      */
isValidSeekbarGrabnull380     private fun isValidSeekbarGrab(): Boolean {
381         if (firstMotionEvent == null || lastMotionEvent == null) {
382             return true
383         }
384         return abs(firstMotionEvent!!.x - lastMotionEvent!!.x) >=
385             abs(firstMotionEvent!!.y - lastMotionEvent!!.y)
386     }
387 
388     /** Listener interface to be notified when the user starts or stops scrubbing. */
389     interface ScrubbingChangeListener {
onScrubbingChangednull390         fun onScrubbingChanged(scrubbing: Boolean)
391     }
392 
393     /** Listener interface to be notified when the seekbar's enabled status changes. */
394     interface EnabledChangeListener {
395         fun onEnabledChanged(enabled: Boolean)
396     }
397 
398     private class SeekBarChangeListener(
399         val viewModel: SeekBarViewModel,
400         val falsingManager: FalsingManager,
401     ) : SeekBar.OnSeekBarChangeListener {
onProgressChangednull402         override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
403             if (fromUser) {
404                 viewModel.onSeekProgress(progress.toLong())
405             }
406         }
407 
onStartTrackingTouchnull408         override fun onStartTrackingTouch(bar: SeekBar) {
409             viewModel.onSeekStarting()
410         }
411 
onStopTrackingTouchnull412         override fun onStopTrackingTouch(bar: SeekBar) {
413             if (!viewModel.isValidSeekbarGrab() || falsingManager.isFalseTouch(MEDIA_SEEKBAR)) {
414                 viewModel.onSeekFalse()
415             }
416             viewModel.onSeek(bar.progress.toLong())
417         }
418     }
419 
420     /**
421      * Responsible for intercepting touch events before they reach the seek bar.
422      *
423      * This reduces the gestures seen by the seek bar so that users don't accidentially seek when
424      * they intend to scroll the carousel.
425      */
426     private class SeekBarTouchListener(
427         private val viewModel: SeekBarViewModel,
428         private val bar: SeekBar,
429     ) : View.OnTouchListener, GestureDetector.OnGestureListener {
430 
431         // Gesture detector helps decide which touch events to intercept.
432         private val detector = GestureDetectorCompat(bar.context, this)
433         // Velocity threshold used to decide when a fling is considered a false gesture.
434         private val flingVelocity: Int =
<lambda>null435             ViewConfiguration.get(bar.context).run {
436                 getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_SCALE_FACTOR
437             }
438         // Indicates if the gesture should go to the seek bar or if it should be intercepted.
439         private var shouldGoToSeekBar = false
440 
441         /**
442          * Decide which touch events to intercept before they reach the seek bar.
443          *
444          * Based on the gesture detected, we decide whether we want the event to reach the seek bar.
445          * If we want the seek bar to see the event, then we return false so that the event isn't
446          * handled here and it will be passed along. If, however, we don't want the seek bar to see
447          * the event, then return true so that the event is handled here.
448          *
449          * When the seek bar is contained in the carousel, the carousel still has the ability to
450          * intercept the touch event. So, even though we may handle the event here, the carousel can
451          * still intercept the event. This way, gestures that we consider falses on the seek bar can
452          * still be used by the carousel for paging.
453          *
454          * Returns true for events that we don't want dispatched to the seek bar.
455          */
onTouchnull456         override fun onTouch(view: View, event: MotionEvent): Boolean {
457             if (view != bar) {
458                 return false
459             }
460             detector.onTouchEvent(event)
461             // Store the last motion event done on seekbar.
462             viewModel.lastMotionEvent = event.copy()
463             return !shouldGoToSeekBar
464         }
465 
466         /**
467          * Handle down events that press down on the thumb.
468          *
469          * On the down action, determine a target box around the thumb to know when a scroll gesture
470          * starts by clicking on the thumb. The target box will be used by subsequent onScroll
471          * events.
472          *
473          * Returns true when the down event hits within the target box of the thumb.
474          */
onDownnull475         override fun onDown(event: MotionEvent): Boolean {
476             val padL = bar.paddingLeft
477             val padR = bar.paddingRight
478             // Compute the X location of the thumb as a function of the seek bar progress.
479             // TODO: account for thumb offset
480             val progress = bar.getProgress()
481             val range = bar.max - bar.min
482             val widthFraction =
483                 if (range > 0) {
484                     (progress - bar.min).toDouble() / range
485                 } else {
486                     0.0
487                 }
488             val availableWidth = bar.width - padL - padR
489             val thumbX =
490                 if (bar.isLayoutRtl()) {
491                     padL + availableWidth * (1 - widthFraction)
492                 } else {
493                     padL + availableWidth * widthFraction
494                 }
495             // Set the min, max boundaries of the thumb box.
496             // I'm cheating by using the height of the seek bar as the width of the box.
497             val halfHeight: Int = bar.height / 2
498             val targetBoxMinX = (Math.round(thumbX) - halfHeight).toInt()
499             val targetBoxMaxX = (Math.round(thumbX) + halfHeight).toInt()
500             // If the x position of the down event is within the box, then request that the parent
501             // not intercept the event.
502             val x = Math.round(event.x)
503             shouldGoToSeekBar = x >= targetBoxMinX && x <= targetBoxMaxX
504             if (shouldGoToSeekBar) {
505                 bar.parent?.requestDisallowInterceptTouchEvent(true)
506             }
507             // Store the first motion event done on seekbar.
508             viewModel.firstMotionEvent = event.copy()
509             return shouldGoToSeekBar
510         }
511 
512         /**
513          * Always handle single tap up.
514          *
515          * This enables the user to single tap anywhere on the seek bar to seek to that position.
516          */
onSingleTapUpnull517         override fun onSingleTapUp(event: MotionEvent): Boolean {
518             shouldGoToSeekBar = true
519             return true
520         }
521 
522         /**
523          * Handle scroll events when the down event is on the thumb.
524          *
525          * Returns true when the down event of the scroll hits within the target box of the thumb.
526          */
onScrollnull527         override fun onScroll(
528             eventStart: MotionEvent?,
529             event: MotionEvent,
530             distanceX: Float,
531             distanceY: Float
532         ): Boolean {
533             return shouldGoToSeekBar
534         }
535 
536         /**
537          * Handle fling events when the down event is on the thumb.
538          *
539          * Gestures that include a fling are considered a false gesture on the seek bar.
540          */
onFlingnull541         override fun onFling(
542             eventStart: MotionEvent?,
543             event: MotionEvent,
544             velocityX: Float,
545             velocityY: Float
546         ): Boolean {
547             if (Math.abs(velocityX) > flingVelocity || Math.abs(velocityY) > flingVelocity) {
548                 viewModel.onSeekFalse()
549             }
550             return shouldGoToSeekBar
551         }
552 
onShowPressnull553         override fun onShowPress(event: MotionEvent) {}
554 
onLongPressnull555         override fun onLongPress(event: MotionEvent) {}
556     }
557 
558     /** State seen by seek bar UI. */
559     data class Progress(
560         val enabled: Boolean,
561         val seekAvailable: Boolean,
562         /** whether playback state is not paused or connecting */
563         val playing: Boolean,
564         val scrubbing: Boolean,
565         val elapsedTime: Int?,
566         val duration: Int,
567         /** whether seekBar is listening to progress updates */
568         val listening: Boolean,
569     )
570 }
571