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