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