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
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 = 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 }
<lambda>null96 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() {
onPlaybackStateChangednull110 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
onSessionDestroyednull119 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) =
<lambda>null131 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
onSeekStartingnull156 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
onSeekProgressnull168 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
onSeekFalsenull186 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
onSeeknull199 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
updateControllernull223 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 */
updateStaticProgressnull251 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
clearControllernull270 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
onDestroynull281 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
checkPlaybackPositionnull292 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
checkIfPollingNeedednull301 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. */
attachTouchHandlersnull335 fun attachTouchHandlers(bar: SeekBar) {
336 bar.setOnSeekBarChangeListener(seekBarListener)
337 bar.setOnTouchListener(SeekBarTouchListener(this, bar))
338 }
339
setScrubbingChangeListenernull340 fun setScrubbingChangeListener(listener: ScrubbingChangeListener) {
341 scrubbingChangeListener = listener
342 }
343
removeScrubbingChangeListenernull344 fun removeScrubbingChangeListener(listener: ScrubbingChangeListener) {
345 if (listener == scrubbingChangeListener) {
346 scrubbingChangeListener = null
347 }
348 }
349
setEnabledChangeListenernull350 fun setEnabledChangeListener(listener: EnabledChangeListener) {
351 enabledChangeListener = listener
352 }
353
removeEnabledChangeListenernull354 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 */
isValidSeekbarGrabnull369 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 {
onScrubbingChangednull379 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 {
onProgressChangednull391 override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
392 if (fromUser) {
393 viewModel.onSeekProgress(progress.toLong())
394 }
395 }
396
onStartTrackingTouchnull397 override fun onStartTrackingTouch(bar: SeekBar) {
398 viewModel.onSeekStarting()
399 }
400
onStopTrackingTouchnull401 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 =
<lambda>null424 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 */
onTouchnull445 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 */
onDownnull464 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 */
onSingleTapUpnull506 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 */
onScrollnull516 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 */
onFlingnull530 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
onShowPressnull542 override fun onShowPress(event: MotionEvent) {}
543
onLongPressnull544 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