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