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.icu.text.MeasureFormat
20 import android.icu.util.Measure
21 import android.icu.util.MeasureUnit
22 import android.media.MediaMetadata
23 import android.media.session.MediaController
24 import android.media.session.PlaybackState
25 import android.os.SystemClock
26 import android.os.Trace
27 import android.text.format.DateUtils
28 import android.view.GestureDetector
29 import android.view.MotionEvent
30 import android.view.View
31 import android.view.ViewConfiguration
32 import android.widget.SeekBar
33 import androidx.annotation.AnyThread
34 import androidx.annotation.VisibleForTesting
35 import androidx.annotation.WorkerThread
36 import androidx.core.view.GestureDetectorCompat
37 import androidx.lifecycle.LiveData
38 import androidx.lifecycle.MutableLiveData
39 import com.android.systemui.classifier.Classifier.MEDIA_SEEKBAR
40 import com.android.systemui.dagger.qualifiers.Background
41 import com.android.systemui.media.NotificationMediaManager
42 import com.android.systemui.plugins.FalsingManager
43 import com.android.systemui.util.concurrency.RepeatableExecutor
44 import java.util.Locale
45 import javax.inject.Inject
46 import kotlin.math.abs
47
48 private const val POSITION_UPDATE_INTERVAL_MILLIS = 500L
49 private const val MIN_FLING_VELOCITY_SCALE_FACTOR = 10
50 private const val MIN_IN_SEC = 60
51 private const val HOUR_IN_SEC = MIN_IN_SEC * 60
52
53 private const val TRACE_POSITION_NAME = "SeekBarPollingPosition"
54
PlaybackStatenull55 private fun PlaybackState.isInMotion(): Boolean {
56 return this.state == PlaybackState.STATE_PLAYING ||
57 this.state == PlaybackState.STATE_FAST_FORWARDING ||
58 this.state == PlaybackState.STATE_REWINDING
59 }
60
61 /**
62 * Gets the playback position while accounting for the time since the [PlaybackState] was last
63 * retrieved.
64 *
65 * This method closely follows the implementation of
66 * [MediaSessionRecord#getStateWithUpdatedPosition].
67 */
computePositionnull68 private fun PlaybackState.computePosition(duration: Long): Long {
69 var currentPosition = this.position
70 if (this.isInMotion()) {
71 val updateTime = this.getLastPositionUpdateTime()
72 val currentTime = SystemClock.elapsedRealtime()
73 if (updateTime > 0) {
74 var position =
75 (this.playbackSpeed * (currentTime - updateTime)).toLong() + this.getPosition()
76 if (duration >= 0 && position > duration) {
77 position = duration.toLong()
78 } else if (position < 0) {
79 position = 0
80 }
81 currentPosition = position
82 }
83 }
84 return currentPosition
85 }
86
87 /** ViewModel for seek bar in QS media player. */
88 class SeekBarViewModel
89 @Inject
90 constructor(
91 @Background private val bgExecutor: RepeatableExecutor,
92 private val falsingManager: FalsingManager,
93 ) {
94 private var _data =
95 Progress(
96 enabled = false,
97 seekAvailable = false,
98 playing = false,
99 scrubbing = false,
100 elapsedTime = null,
101 duration = 0,
102 listening = false,
103 )
104 set(value) {
105 val enabledChanged = value.enabled != field.enabled
106 field = value
107 if (enabledChanged) {
108 enabledChangeListener?.onEnabledChanged(value.enabled)
109 }
110 _progress.postValue(value)
111
<lambda>null112 bgExecutor.execute {
113 val durationDescription = formatTimeContentDescription(value.duration)
114 val elapsedDescription =
115 value.elapsedTime?.let { formatTimeContentDescription(it) } ?: ""
116 contentDescriptionListener?.onContentDescriptionChanged(
117 elapsedDescription,
118 durationDescription,
119 )
120 }
121 }
122
<lambda>null123 private val _progress = MutableLiveData<Progress>().apply { postValue(_data) }
124 val progress: LiveData<Progress>
125 get() = _progress
126
127 private var controller: MediaController? = null
128 set(value) {
129 if (field?.sessionToken != value?.sessionToken) {
130 field?.unregisterCallback(callback)
131 value?.registerCallback(callback)
132 field = value
133 }
134 }
135
136 private var playbackState: PlaybackState? = null
137 private var callback =
138 object : MediaController.Callback() {
onPlaybackStateChangednull139 override fun onPlaybackStateChanged(state: PlaybackState?) {
140 playbackState = state
141 if (playbackState == null || PlaybackState.STATE_NONE.equals(playbackState)) {
142 clearController()
143 } else {
144 checkIfPollingNeeded()
145 }
146 }
147
onSessionDestroyednull148 override fun onSessionDestroyed() {
149 clearController()
150 }
151
onMetadataChangednull152 override fun onMetadataChanged(metadata: MediaMetadata?) {
153 val (enabled, duration) = getEnabledStateAndDuration(metadata)
154 if (_data.duration != duration) {
155 _data = _data.copy(enabled = enabled, duration = duration)
156 }
157 }
158 }
159 private var cancel: Runnable? = null
160
161 /** Indicates if the seek interaction is considered a false guesture. */
162 private var isFalseSeek = false
163
164 /** Listening state (QS open or closed) is used to control polling of progress. */
165 var listening = true
166 set(value) =
<lambda>null167 bgExecutor.execute {
168 if (field != value) {
169 field = value
170 checkIfPollingNeeded()
171 _data = _data.copy(listening = value)
172 }
173 }
174
175 private var scrubbingChangeListener: ScrubbingChangeListener? = null
176 private var enabledChangeListener: EnabledChangeListener? = null
177 private var contentDescriptionListener: ContentDescriptionListener? = null
178
179 /** Set to true when the user is touching the seek bar to change the position. */
180 private var scrubbing = false
181 set(value) {
182 if (field != value) {
183 field = value
184 checkIfPollingNeeded()
185 scrubbingChangeListener?.onScrubbingChanged(value)
186 _data = _data.copy(scrubbing = value)
187 }
188 }
189
190 lateinit var logSeek: () -> Unit
191
192 /** Event indicating that the user has started interacting with the seek bar. */
193 @AnyThread
onSeekStartingnull194 fun onSeekStarting() =
195 bgExecutor.execute {
196 scrubbing = true
197 isFalseSeek = false
198 }
199
200 /**
201 * Event indicating that the user has moved the seek bar.
202 *
203 * @param position Current location in the track.
204 */
205 @AnyThread
onSeekProgressnull206 fun onSeekProgress(position: Long) =
207 bgExecutor.execute {
208 if (scrubbing) {
209 // The user hasn't yet finished their touch gesture, so only update the data for
210 // visual
211 // feedback and don't update [controller] yet.
212 _data = _data.copy(elapsedTime = position.toInt())
213 } else {
214 // The seek progress came from an a11y action and we should immediately update to
215 // the
216 // new position. (a11y actions to change the seekbar position don't trigger
217 // SeekBar.OnSeekBarChangeListener.onStartTrackingTouch or onStopTrackingTouch.)
218 onSeek(position)
219 }
220 }
221
222 /** Event indicating that the seek interaction is a false gesture and it should be ignored. */
223 @AnyThread
onSeekFalsenull224 fun onSeekFalse() =
225 bgExecutor.execute {
226 if (scrubbing) {
227 isFalseSeek = true
228 }
229 }
230
231 /**
232 * Handle request to change the current position in the media track.
233 *
234 * @param position Place to seek to in the track.
235 */
236 @AnyThread
onSeeknull237 fun onSeek(position: Long) =
238 bgExecutor.execute {
239 if (isFalseSeek) {
240 scrubbing = false
241 checkPlaybackPosition()
242 } else {
243 logSeek()
244 controller?.transportControls?.seekTo(position)
245 // Invalidate the cached playbackState to avoid the thumb jumping back to the
246 // previous
247 // position.
248 playbackState = null
249 scrubbing = false
250 }
251 }
252
253 /**
254 * Updates media information.
255 *
256 * This function makes a binder call, so it must happen on a worker thread.
257 *
258 * @param mediaController controller for media session
259 */
260 @WorkerThread
updateControllernull261 fun updateController(mediaController: MediaController?) {
262 controller = mediaController
263 playbackState = controller?.playbackState
264 val (enabled, duration) = getEnabledStateAndDuration(controller?.metadata)
265 val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L
266 val position = playbackState?.position?.toInt()
267 val playing =
268 NotificationMediaManager.isPlayingState(
269 playbackState?.state ?: PlaybackState.STATE_NONE
270 )
271 _data = Progress(enabled, seekAvailable, playing, scrubbing, position, duration, listening)
272 // No need to update since we just set the progress info
273 checkIfPollingNeeded(requireUpdate = false)
274 }
275
276 /**
277 * Set the progress to a fixed percentage value that cannot be changed by the user.
278 *
279 * @param percent value between 0 and 1
280 */
updateStaticProgressnull281 fun updateStaticProgress(percent: Double) {
282 val position = (percent * 100).toInt()
283 _data =
284 Progress(
285 enabled = true,
286 seekAvailable = false,
287 playing = false,
288 scrubbing = false,
289 elapsedTime = position,
290 duration = 100,
291 listening = false,
292 )
293 }
294
295 /**
296 * Puts the seek bar into a resumption state.
297 *
298 * This should be called when the media session behind the controller has been destroyed.
299 */
300 @AnyThread
clearControllernull301 fun clearController() =
302 bgExecutor.execute {
303 controller = null
304 playbackState = null
305 cancel?.run()
306 cancel = null
307 _data = _data.copy(enabled = false)
308 }
309
310 /** Call to clean up any resources. */
311 @AnyThread
onDestroynull312 fun onDestroy() =
313 bgExecutor.execute {
314 controller = null
315 playbackState = null
316 cancel?.run()
317 cancel = null
318 scrubbingChangeListener = null
319 enabledChangeListener = null
320 }
321
322 @WorkerThread
checkPlaybackPositionnull323 private fun checkPlaybackPosition() {
324 val duration = _data.duration ?: -1
325 val currentPosition = playbackState?.computePosition(duration.toLong())?.toInt()
326 if (currentPosition != null && _data.elapsedTime != currentPosition) {
327 _data = _data.copy(elapsedTime = currentPosition)
328 }
329 }
330
331 /**
332 * Begin polling if needed given the current seekbar state
333 *
334 * @param requireUpdate If true, update the playback position without beginning polling
335 */
336 @WorkerThread
checkIfPollingNeedednull337 private fun checkIfPollingNeeded(requireUpdate: Boolean = true) {
338 val needed = listening && !scrubbing && playbackState?.isInMotion() ?: false
339 val traceCookie = controller?.sessionToken.hashCode()
340 if (needed) {
341 if (cancel == null) {
342 Trace.beginAsyncSection(TRACE_POSITION_NAME, traceCookie)
343 val cancelPolling =
344 bgExecutor.executeRepeatedly(
345 this::checkPlaybackPosition,
346 0L,
347 POSITION_UPDATE_INTERVAL_MILLIS,
348 )
349 cancel = Runnable {
350 cancelPolling.run()
351 Trace.endAsyncSection(TRACE_POSITION_NAME, traceCookie)
352 }
353 }
354 } else if (requireUpdate) {
355 checkPlaybackPosition()
356 cancel?.run()
357 cancel = null
358 }
359 }
360
361 /** Gets a listener to attach to the seek bar to handle seeking. */
362 val seekBarListener: SeekBar.OnSeekBarChangeListener
363 get() {
364 return SeekBarChangeListener(this, falsingManager)
365 }
366
367 /** first and last motion events of seekbar grab. */
368 @VisibleForTesting var firstMotionEvent: MotionEvent? = null
369 @VisibleForTesting var lastMotionEvent: MotionEvent? = null
370
371 /** Attach touch handlers to the seek bar view. */
attachTouchHandlersnull372 fun attachTouchHandlers(bar: SeekBar) {
373 bar.setOnSeekBarChangeListener(seekBarListener)
374 bar.setOnTouchListener(SeekBarTouchListener(this, bar))
375 }
376
setScrubbingChangeListenernull377 fun setScrubbingChangeListener(listener: ScrubbingChangeListener) {
378 scrubbingChangeListener = listener
379 }
380
removeScrubbingChangeListenernull381 fun removeScrubbingChangeListener(listener: ScrubbingChangeListener) {
382 if (listener == scrubbingChangeListener) {
383 scrubbingChangeListener = null
384 }
385 }
386
setEnabledChangeListenernull387 fun setEnabledChangeListener(listener: EnabledChangeListener) {
388 enabledChangeListener = listener
389 }
390
removeEnabledChangeListenernull391 fun removeEnabledChangeListener(listener: EnabledChangeListener) {
392 if (listener == enabledChangeListener) {
393 enabledChangeListener = null
394 }
395 }
396
setContentDescriptionListenernull397 fun setContentDescriptionListener(listener: ContentDescriptionListener) {
398 contentDescriptionListener = listener
399 }
400
removeContentDescriptionListenernull401 fun removeContentDescriptionListener(listener: ContentDescriptionListener) {
402 if (listener == contentDescriptionListener) {
403 contentDescriptionListener = null
404 }
405 }
406
407 /** returns a pair of whether seekbar is enabled and the duration of media. */
getEnabledStateAndDurationnull408 private fun getEnabledStateAndDuration(metadata: MediaMetadata?): Pair<Boolean, Int> {
409 val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt() ?: 0
410 val enabled =
411 !(playbackState == null ||
412 playbackState?.state == PlaybackState.STATE_NONE ||
413 (duration <= 0))
414 return Pair(enabled, duration)
415 }
416
417 /**
418 * This method specifies if user made a bad seekbar grab or not. If the vertical distance from
419 * first touch on seekbar is more than the horizontal distance, this means that the seekbar grab
420 * is more vertical and should be rejected. Seekbar accepts horizontal grabs only.
421 *
422 * Single tap has the same first and last motion event, it is counted as a valid grab.
423 *
424 * @return whether the touch on seekbar is valid.
425 */
isValidSeekbarGrabnull426 private fun isValidSeekbarGrab(): Boolean {
427 if (firstMotionEvent == null || lastMotionEvent == null) {
428 return true
429 }
430 return abs(firstMotionEvent!!.x - lastMotionEvent!!.x) >=
431 abs(firstMotionEvent!!.y - lastMotionEvent!!.y)
432 }
433
434 /**
435 * Returns a time string suitable for content description, e.g. "12 minutes 34 seconds"
436 *
437 * Follows same logic as Chronometer#formatDuration
438 */
formatTimeContentDescriptionnull439 private fun formatTimeContentDescription(milliseconds: Int): CharSequence {
440 var seconds = milliseconds / DateUtils.SECOND_IN_MILLIS
441
442 val hours =
443 if (seconds >= HOUR_IN_SEC) {
444 seconds / HOUR_IN_SEC
445 } else {
446 0
447 }
448 seconds -= hours * HOUR_IN_SEC
449
450 val minutes =
451 if (seconds >= MIN_IN_SEC) {
452 seconds / MIN_IN_SEC
453 } else {
454 0
455 }
456 seconds -= minutes * MIN_IN_SEC
457
458 val measures = arrayListOf<Measure>()
459 if (hours > 0) {
460 measures.add(Measure(hours, MeasureUnit.HOUR))
461 }
462 if (minutes > 0) {
463 measures.add(Measure(minutes, MeasureUnit.MINUTE))
464 }
465 measures.add(Measure(seconds, MeasureUnit.SECOND))
466
467 return MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE)
468 .formatMeasures(*measures.toTypedArray())
469 }
470
471 /** Listener interface to be notified when the user starts or stops scrubbing. */
472 interface ScrubbingChangeListener {
onScrubbingChangednull473 fun onScrubbingChanged(scrubbing: Boolean)
474 }
475
476 /** Listener interface to be notified when the seekbar's enabled status changes. */
477 interface EnabledChangeListener {
478 fun onEnabledChanged(enabled: Boolean)
479 }
480
481 interface ContentDescriptionListener {
onContentDescriptionChangednull482 fun onContentDescriptionChanged(
483 elapsedTimeDescription: CharSequence,
484 durationDescription: CharSequence,
485 )
486 }
487
488 private class SeekBarChangeListener(
489 val viewModel: SeekBarViewModel,
490 val falsingManager: FalsingManager,
491 ) : SeekBar.OnSeekBarChangeListener {
492 override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
493 if (fromUser) {
494 viewModel.onSeekProgress(progress.toLong())
495 }
496 }
497
498 override fun onStartTrackingTouch(bar: SeekBar) {
499 viewModel.onSeekStarting()
500 }
501
502 override fun onStopTrackingTouch(bar: SeekBar) {
503 if (!viewModel.isValidSeekbarGrab() || falsingManager.isFalseTouch(MEDIA_SEEKBAR)) {
504 viewModel.onSeekFalse()
505 }
506 viewModel.onSeek(bar.progress.toLong())
507 }
508 }
509
510 /**
511 * Responsible for intercepting touch events before they reach the seek bar.
512 *
513 * This reduces the gestures seen by the seek bar so that users don't accidentially seek when
514 * they intend to scroll the carousel.
515 */
516 private class SeekBarTouchListener(
517 private val viewModel: SeekBarViewModel,
518 private val bar: SeekBar,
519 ) : View.OnTouchListener, GestureDetector.OnGestureListener {
520
521 // Gesture detector helps decide which touch events to intercept.
522 private val detector = GestureDetectorCompat(bar.context, this)
523 // Velocity threshold used to decide when a fling is considered a false gesture.
524 private val flingVelocity: Int =
<lambda>null525 ViewConfiguration.get(bar.context).run {
526 getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_SCALE_FACTOR
527 }
528 // Indicates if the gesture should go to the seek bar or if it should be intercepted.
529 private var shouldGoToSeekBar = false
530
531 /**
532 * Decide which touch events to intercept before they reach the seek bar.
533 *
534 * Based on the gesture detected, we decide whether we want the event to reach the seek bar.
535 * If we want the seek bar to see the event, then we return false so that the event isn't
536 * handled here and it will be passed along. If, however, we don't want the seek bar to see
537 * the event, then return true so that the event is handled here.
538 *
539 * When the seek bar is contained in the carousel, the carousel still has the ability to
540 * intercept the touch event. So, even though we may handle the event here, the carousel can
541 * still intercept the event. This way, gestures that we consider falses on the seek bar can
542 * still be used by the carousel for paging.
543 *
544 * Returns true for events that we don't want dispatched to the seek bar.
545 */
onTouchnull546 override fun onTouch(view: View, event: MotionEvent): Boolean {
547 if (view != bar) {
548 return false
549 }
550 detector.onTouchEvent(event)
551 // Store the last motion event done on seekbar.
552 viewModel.lastMotionEvent = event.copy()
553 return !shouldGoToSeekBar
554 }
555
556 /**
557 * Handle down events that press down on the thumb.
558 *
559 * On the down action, determine a target box around the thumb to know when a scroll gesture
560 * starts by clicking on the thumb. The target box will be used by subsequent onScroll
561 * events.
562 *
563 * Returns true when the down event hits within the target box of the thumb.
564 */
onDownnull565 override fun onDown(event: MotionEvent): Boolean {
566 val padL = bar.paddingLeft
567 val padR = bar.paddingRight
568 // Compute the X location of the thumb as a function of the seek bar progress.
569 // TODO: account for thumb offset
570 val progress = bar.getProgress()
571 val range = bar.max - bar.min
572 val widthFraction =
573 if (range > 0) {
574 (progress - bar.min).toDouble() / range
575 } else {
576 0.0
577 }
578 val availableWidth = bar.width - padL - padR
579 val thumbX =
580 if (bar.isLayoutRtl()) {
581 padL + availableWidth * (1 - widthFraction)
582 } else {
583 padL + availableWidth * widthFraction
584 }
585 // Set the min, max boundaries of the thumb box.
586 // I'm cheating by using the height of the seek bar as the width of the box.
587 val halfHeight: Int = bar.height / 2
588 val targetBoxMinX = (Math.round(thumbX) - halfHeight).toInt()
589 val targetBoxMaxX = (Math.round(thumbX) + halfHeight).toInt()
590 // If the x position of the down event is within the box, then request that the parent
591 // not intercept the event.
592 val x = Math.round(event.x)
593 shouldGoToSeekBar = x >= targetBoxMinX && x <= targetBoxMaxX
594 if (shouldGoToSeekBar) {
595 bar.parent?.requestDisallowInterceptTouchEvent(true)
596 }
597 // Store the first motion event done on seekbar.
598 viewModel.firstMotionEvent = event.copy()
599 return shouldGoToSeekBar
600 }
601
602 /**
603 * Always handle single tap up.
604 *
605 * This enables the user to single tap anywhere on the seek bar to seek to that position.
606 */
onSingleTapUpnull607 override fun onSingleTapUp(event: MotionEvent): Boolean {
608 shouldGoToSeekBar = true
609 return true
610 }
611
612 /**
613 * Handle scroll events when the down event is on the thumb.
614 *
615 * Returns true when the down event of the scroll hits within the target box of the thumb.
616 */
onScrollnull617 override fun onScroll(
618 eventStart: MotionEvent?,
619 event: MotionEvent,
620 distanceX: Float,
621 distanceY: Float,
622 ): Boolean {
623 return shouldGoToSeekBar
624 }
625
626 /**
627 * Handle fling events when the down event is on the thumb.
628 *
629 * Gestures that include a fling are considered a false gesture on the seek bar.
630 */
onFlingnull631 override fun onFling(
632 eventStart: MotionEvent?,
633 event: MotionEvent,
634 velocityX: Float,
635 velocityY: Float,
636 ): Boolean {
637 if (Math.abs(velocityX) > flingVelocity || Math.abs(velocityY) > flingVelocity) {
638 viewModel.onSeekFalse()
639 }
640 return shouldGoToSeekBar
641 }
642
onShowPressnull643 override fun onShowPress(event: MotionEvent) {}
644
onLongPressnull645 override fun onLongPress(event: MotionEvent) {}
646 }
647
648 /** State seen by seek bar UI. */
649 data class Progress(
650 val enabled: Boolean,
651 val seekAvailable: Boolean,
652 /** whether playback state is not paused or connecting */
653 val playing: Boolean,
654 val scrubbing: Boolean,
655 val elapsedTime: Int?,
656 val duration: Int,
657 /** whether seekBar is listening to progress updates */
658 val listening: Boolean,
659 )
660 }
661