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
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.dagger.qualifiers.Background
34 import com.android.systemui.util.concurrency.RepeatableExecutor
35 import javax.inject.Inject
36
37 private const val POSITION_UPDATE_INTERVAL_MILLIS = 100L
38 private const val MIN_FLING_VELOCITY_SCALE_FACTOR = 10
39
PlaybackStatenull40 private fun PlaybackState.isInMotion(): Boolean {
41 return this.state == PlaybackState.STATE_PLAYING ||
42 this.state == PlaybackState.STATE_FAST_FORWARDING ||
43 this.state == PlaybackState.STATE_REWINDING
44 }
45
46 /**
47 * Gets the playback position while accounting for the time since the [PlaybackState] was last
48 * retrieved.
49 *
50 * This method closely follows the implementation of
51 * [MediaSessionRecord#getStateWithUpdatedPosition].
52 */
computePositionnull53 private fun PlaybackState.computePosition(duration: Long): Long {
54 var currentPosition = this.position
55 if (this.isInMotion()) {
56 val updateTime = this.getLastPositionUpdateTime()
57 val currentTime = SystemClock.elapsedRealtime()
58 if (updateTime > 0) {
59 var position = (this.playbackSpeed * (currentTime - updateTime)).toLong() +
60 this.getPosition()
61 if (duration >= 0 && position > duration) {
62 position = duration.toLong()
63 } else if (position < 0) {
64 position = 0
65 }
66 currentPosition = position
67 }
68 }
69 return currentPosition
70 }
71
72 /** ViewModel for seek bar in QS media player. */
73 class SeekBarViewModel @Inject constructor(
74 @Background private val bgExecutor: RepeatableExecutor
75 ) {
76 private var _data = Progress(false, false, null, 0)
77 set(value) {
78 field = value
79 _progress.postValue(value)
80 }
<lambda>null81 private val _progress = MutableLiveData<Progress>().apply {
82 postValue(_data)
83 }
84 val progress: LiveData<Progress>
85 get() = _progress
86 private var controller: MediaController? = null
87 set(value) {
88 if (field?.sessionToken != value?.sessionToken) {
89 field?.unregisterCallback(callback)
90 value?.registerCallback(callback)
91 field = value
92 }
93 }
94 private var playbackState: PlaybackState? = null
95 private var callback = object : MediaController.Callback() {
onPlaybackStateChangednull96 override fun onPlaybackStateChanged(state: PlaybackState?) {
97 playbackState = state
98 if (playbackState == null || PlaybackState.STATE_NONE.equals(playbackState)) {
99 clearController()
100 } else {
101 checkIfPollingNeeded()
102 }
103 }
104
onSessionDestroyednull105 override fun onSessionDestroyed() {
106 clearController()
107 }
108 }
109 private var cancel: Runnable? = null
110
111 /** Indicates if the seek interaction is considered a false guesture. */
112 private var isFalseSeek = false
113
114 /** Listening state (QS open or closed) is used to control polling of progress. */
115 var listening = true
<lambda>null116 set(value) = bgExecutor.execute {
117 if (field != value) {
118 field = value
119 checkIfPollingNeeded()
120 }
121 }
122
123 /** Set to true when the user is touching the seek bar to change the position. */
124 private var scrubbing = false
125 set(value) {
126 if (field != value) {
127 field = value
128 checkIfPollingNeeded()
129 }
130 }
131
132 lateinit var logSmartspaceClick: () -> Unit
133
134 /**
135 * Event indicating that the user has started interacting with the seek bar.
136 */
137 @AnyThread
<lambda>null138 fun onSeekStarting() = bgExecutor.execute {
139 scrubbing = true
140 isFalseSeek = false
141 }
142
143 /**
144 * Event indicating that the user has moved the seek bar but hasn't yet finished the gesture.
145 * @param position Current location in the track.
146 */
147 @AnyThread
<lambda>null148 fun onSeekProgress(position: Long) = bgExecutor.execute {
149 if (scrubbing) {
150 _data = _data.copy(elapsedTime = position.toInt())
151 }
152 }
153
154 /**
155 * Event indicating that the seek interaction is a false gesture and it should be ignored.
156 */
157 @AnyThread
<lambda>null158 fun onSeekFalse() = bgExecutor.execute {
159 if (scrubbing) {
160 isFalseSeek = true
161 }
162 }
163
164 /**
165 * Handle request to change the current position in the media track.
166 * @param position Place to seek to in the track.
167 */
168 @AnyThread
<lambda>null169 fun onSeek(position: Long) = bgExecutor.execute {
170 if (isFalseSeek) {
171 scrubbing = false
172 checkPlaybackPosition()
173 } else {
174 logSmartspaceClick()
175 controller?.transportControls?.seekTo(position)
176 // Invalidate the cached playbackState to avoid the thumb jumping back to the previous
177 // position.
178 playbackState = null
179 scrubbing = false
180 }
181 }
182
183 /**
184 * Updates media information.
185 * @param mediaController controller for media session
186 */
187 @WorkerThread
updateControllernull188 fun updateController(mediaController: MediaController?) {
189 controller = mediaController
190 playbackState = controller?.playbackState
191 val mediaMetadata = controller?.metadata
192 val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L
193 val position = playbackState?.position?.toInt()
194 val duration = mediaMetadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt() ?: 0
195 val enabled = if (playbackState == null ||
196 playbackState?.getState() == PlaybackState.STATE_NONE ||
197 (duration <= 0)) false else true
198 _data = Progress(enabled, seekAvailable, position, duration)
199 checkIfPollingNeeded()
200 }
201
202 /**
203 * Puts the seek bar into a resumption state.
204 *
205 * This should be called when the media session behind the controller has been destroyed.
206 */
207 @AnyThread
<lambda>null208 fun clearController() = bgExecutor.execute {
209 controller = null
210 playbackState = null
211 cancel?.run()
212 cancel = null
213 _data = _data.copy(enabled = false)
214 }
215
216 /**
217 * Call to clean up any resources.
218 */
219 @AnyThread
<lambda>null220 fun onDestroy() = bgExecutor.execute {
221 controller = null
222 playbackState = null
223 cancel?.run()
224 cancel = null
225 }
226
227 @WorkerThread
checkPlaybackPositionnull228 private fun checkPlaybackPosition() {
229 val duration = _data.duration ?: -1
230 val currentPosition = playbackState?.computePosition(duration.toLong())?.toInt()
231 if (currentPosition != null && _data.elapsedTime != currentPosition) {
232 _data = _data.copy(elapsedTime = currentPosition)
233 }
234 }
235
236 @WorkerThread
checkIfPollingNeedednull237 private fun checkIfPollingNeeded() {
238 val needed = listening && !scrubbing && playbackState?.isInMotion() ?: false
239 if (needed) {
240 if (cancel == null) {
241 cancel = bgExecutor.executeRepeatedly(this::checkPlaybackPosition, 0L,
242 POSITION_UPDATE_INTERVAL_MILLIS)
243 }
244 } else {
245 cancel?.run()
246 cancel = null
247 }
248 }
249
250 /** Gets a listener to attach to the seek bar to handle seeking. */
251 val seekBarListener: SeekBar.OnSeekBarChangeListener
252 get() {
253 return SeekBarChangeListener(this)
254 }
255
256 /** Attach touch handlers to the seek bar view. */
attachTouchHandlersnull257 fun attachTouchHandlers(bar: SeekBar) {
258 bar.setOnSeekBarChangeListener(seekBarListener)
259 bar.setOnTouchListener(SeekBarTouchListener(this, bar))
260 }
261
262 private class SeekBarChangeListener(
263 val viewModel: SeekBarViewModel
264 ) : SeekBar.OnSeekBarChangeListener {
onProgressChangednull265 override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
266 if (fromUser) {
267 viewModel.onSeekProgress(progress.toLong())
268 }
269 }
270
onStartTrackingTouchnull271 override fun onStartTrackingTouch(bar: SeekBar) {
272 viewModel.onSeekStarting()
273 }
274
onStopTrackingTouchnull275 override fun onStopTrackingTouch(bar: SeekBar) {
276 viewModel.onSeek(bar.progress.toLong())
277 }
278 }
279
280 /**
281 * Responsible for intercepting touch events before they reach the seek bar.
282 *
283 * This reduces the gestures seen by the seek bar so that users don't accidentially seek when
284 * they intend to scroll the carousel.
285 */
286 private class SeekBarTouchListener(
287 private val viewModel: SeekBarViewModel,
288 private val bar: SeekBar
289 ) : View.OnTouchListener, GestureDetector.OnGestureListener {
290
291 // Gesture detector helps decide which touch events to intercept.
292 private val detector = GestureDetectorCompat(bar.context, this)
293 // Velocity threshold used to decide when a fling is considered a false gesture.
<lambda>null294 private val flingVelocity: Int = ViewConfiguration.get(bar.context).run {
295 getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_SCALE_FACTOR
296 }
297 // Indicates if the gesture should go to the seek bar or if it should be intercepted.
298 private var shouldGoToSeekBar = false
299
300 /**
301 * Decide which touch events to intercept before they reach the seek bar.
302 *
303 * Based on the gesture detected, we decide whether we want the event to reach the seek bar.
304 * If we want the seek bar to see the event, then we return false so that the event isn't
305 * handled here and it will be passed along. If, however, we don't want the seek bar to see
306 * the event, then return true so that the event is handled here.
307 *
308 * When the seek bar is contained in the carousel, the carousel still has the ability to
309 * intercept the touch event. So, even though we may handle the event here, the carousel can
310 * still intercept the event. This way, gestures that we consider falses on the seek bar can
311 * still be used by the carousel for paging.
312 *
313 * Returns true for events that we don't want dispatched to the seek bar.
314 */
onTouchnull315 override fun onTouch(view: View, event: MotionEvent): Boolean {
316 if (view != bar) {
317 return false
318 }
319 detector.onTouchEvent(event)
320 return !shouldGoToSeekBar
321 }
322
323 /**
324 * Handle down events that press down on the thumb.
325 *
326 * On the down action, determine a target box around the thumb to know when a scroll
327 * gesture starts by clicking on the thumb. The target box will be used by subsequent
328 * onScroll events.
329 *
330 * Returns true when the down event hits within the target box of the thumb.
331 */
onDownnull332 override fun onDown(event: MotionEvent): Boolean {
333 val padL = bar.paddingLeft
334 val padR = bar.paddingRight
335 // Compute the X location of the thumb as a function of the seek bar progress.
336 // TODO: account for thumb offset
337 val progress = bar.getProgress()
338 val range = bar.max - bar.min
339 val widthFraction = if (range > 0) {
340 (progress - bar.min).toDouble() / range
341 } else {
342 0.0
343 }
344 val availableWidth = bar.width - padL - padR
345 val thumbX = if (bar.isLayoutRtl()) {
346 padL + availableWidth * (1 - widthFraction)
347 } else {
348 padL + availableWidth * widthFraction
349 }
350 // Set the min, max boundaries of the thumb box.
351 // I'm cheating by using the height of the seek bar as the width of the box.
352 val halfHeight: Int = bar.height / 2
353 val targetBoxMinX = (Math.round(thumbX) - halfHeight).toInt()
354 val targetBoxMaxX = (Math.round(thumbX) + halfHeight).toInt()
355 // If the x position of the down event is within the box, then request that the parent
356 // not intercept the event.
357 val x = Math.round(event.x)
358 shouldGoToSeekBar = x >= targetBoxMinX && x <= targetBoxMaxX
359 if (shouldGoToSeekBar) {
360 bar.parent?.requestDisallowInterceptTouchEvent(true)
361 }
362 return shouldGoToSeekBar
363 }
364
365 /**
366 * Always handle single tap up.
367 *
368 * This enables the user to single tap anywhere on the seek bar to seek to that position.
369 */
onSingleTapUpnull370 override fun onSingleTapUp(event: MotionEvent): Boolean {
371 shouldGoToSeekBar = true
372 return true
373 }
374
375 /**
376 * Handle scroll events when the down event is on the thumb.
377 *
378 * Returns true when the down event of the scroll hits within the target box of the thumb.
379 */
onScrollnull380 override fun onScroll(
381 eventStart: MotionEvent,
382 event: MotionEvent,
383 distanceX: Float,
384 distanceY: Float
385 ): Boolean {
386 return shouldGoToSeekBar
387 }
388
389 /**
390 * Handle fling events when the down event is on the thumb.
391 *
392 * Gestures that include a fling are considered a false gesture on the seek bar.
393 */
onFlingnull394 override fun onFling(
395 eventStart: MotionEvent,
396 event: MotionEvent,
397 velocityX: Float,
398 velocityY: Float
399 ): Boolean {
400 if (Math.abs(velocityX) > flingVelocity || Math.abs(velocityY) > flingVelocity) {
401 viewModel.onSeekFalse()
402 }
403 return shouldGoToSeekBar
404 }
405
onShowPressnull406 override fun onShowPress(event: MotionEvent) {}
407
onLongPressnull408 override fun onLongPress(event: MotionEvent) {}
409 }
410
411 /** State seen by seek bar UI. */
412 data class Progress(
413 val enabled: Boolean,
414 val seekAvailable: Boolean,
415 val elapsedTime: Int?,
416 val duration: Int
417 )
418 }
419