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