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