• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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