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.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 48 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 */ 61 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 = Progress(false, false, false, false, null, 0) 88 set(value) { 89 val enabledChanged = value.enabled != field.enabled 90 field = value 91 if (enabledChanged) { 92 enabledChangeListener?.onEnabledChanged(value.enabled) 93 } 94 _progress.postValue(value) 95 } 96 private val _progress = MutableLiveData<Progress>().apply { postValue(_data) } 97 val progress: LiveData<Progress> 98 get() = _progress 99 private var controller: MediaController? = null 100 set(value) { 101 if (field?.sessionToken != value?.sessionToken) { 102 field?.unregisterCallback(callback) 103 value?.registerCallback(callback) 104 field = value 105 } 106 } 107 private var playbackState: PlaybackState? = null 108 private var callback = 109 object : MediaController.Callback() { 110 override fun onPlaybackStateChanged(state: PlaybackState?) { 111 playbackState = state 112 if (playbackState == null || PlaybackState.STATE_NONE.equals(playbackState)) { 113 clearController() 114 } else { 115 checkIfPollingNeeded() 116 } 117 } 118 119 override fun onSessionDestroyed() { 120 clearController() 121 } 122 } 123 private var cancel: Runnable? = null 124 125 /** Indicates if the seek interaction is considered a false guesture. */ 126 private var isFalseSeek = false 127 128 /** Listening state (QS open or closed) is used to control polling of progress. */ 129 var listening = true 130 set(value) = 131 bgExecutor.execute { 132 if (field != value) { 133 field = value 134 checkIfPollingNeeded() 135 } 136 } 137 138 private var scrubbingChangeListener: ScrubbingChangeListener? = null 139 private var enabledChangeListener: EnabledChangeListener? = null 140 141 /** Set to true when the user is touching the seek bar to change the position. */ 142 private var scrubbing = false 143 set(value) { 144 if (field != value) { 145 field = value 146 checkIfPollingNeeded() 147 scrubbingChangeListener?.onScrubbingChanged(value) 148 _data = _data.copy(scrubbing = value) 149 } 150 } 151 152 lateinit var logSeek: () -> Unit 153 154 /** Event indicating that the user has started interacting with the seek bar. */ 155 @AnyThread 156 fun onSeekStarting() = 157 bgExecutor.execute { 158 scrubbing = true 159 isFalseSeek = false 160 } 161 162 /** 163 * Event indicating that the user has moved the seek bar. 164 * 165 * @param position Current location in the track. 166 */ 167 @AnyThread 168 fun onSeekProgress(position: Long) = 169 bgExecutor.execute { 170 if (scrubbing) { 171 // The user hasn't yet finished their touch gesture, so only update the data for 172 // visual 173 // feedback and don't update [controller] yet. 174 _data = _data.copy(elapsedTime = position.toInt()) 175 } else { 176 // The seek progress came from an a11y action and we should immediately update to 177 // the 178 // new position. (a11y actions to change the seekbar position don't trigger 179 // SeekBar.OnSeekBarChangeListener.onStartTrackingTouch or onStopTrackingTouch.) 180 onSeek(position) 181 } 182 } 183 184 /** Event indicating that the seek interaction is a false gesture and it should be ignored. */ 185 @AnyThread 186 fun onSeekFalse() = 187 bgExecutor.execute { 188 if (scrubbing) { 189 isFalseSeek = true 190 } 191 } 192 193 /** 194 * Handle request to change the current position in the media track. 195 * 196 * @param position Place to seek to in the track. 197 */ 198 @AnyThread 199 fun onSeek(position: Long) = 200 bgExecutor.execute { 201 if (isFalseSeek) { 202 scrubbing = false 203 checkPlaybackPosition() 204 } else { 205 logSeek() 206 controller?.transportControls?.seekTo(position) 207 // Invalidate the cached playbackState to avoid the thumb jumping back to the 208 // previous 209 // position. 210 playbackState = null 211 scrubbing = false 212 } 213 } 214 215 /** 216 * Updates media information. 217 * 218 * This function makes a binder call, so it must happen on a worker thread. 219 * 220 * @param mediaController controller for media session 221 */ 222 @WorkerThread 223 fun updateController(mediaController: MediaController?) { 224 controller = mediaController 225 playbackState = controller?.playbackState 226 val mediaMetadata = controller?.metadata 227 val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L 228 val position = playbackState?.position?.toInt() 229 val duration = mediaMetadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt() ?: 0 230 val playing = 231 NotificationMediaManager.isPlayingState( 232 playbackState?.state ?: PlaybackState.STATE_NONE 233 ) 234 val enabled = 235 if ( 236 playbackState == null || 237 playbackState?.getState() == PlaybackState.STATE_NONE || 238 (duration <= 0) 239 ) 240 false 241 else true 242 _data = Progress(enabled, seekAvailable, playing, scrubbing, position, duration) 243 checkIfPollingNeeded() 244 } 245 246 /** 247 * Set the progress to a fixed percentage value that cannot be changed by the user. 248 * 249 * @param percent value between 0 and 1 250 */ 251 fun updateStaticProgress(percent: Double) { 252 val position = (percent * 100).toInt() 253 _data = 254 Progress( 255 enabled = true, 256 seekAvailable = false, 257 playing = false, 258 scrubbing = false, 259 elapsedTime = position, 260 duration = 100, 261 ) 262 } 263 264 /** 265 * Puts the seek bar into a resumption state. 266 * 267 * This should be called when the media session behind the controller has been destroyed. 268 */ 269 @AnyThread 270 fun clearController() = 271 bgExecutor.execute { 272 controller = null 273 playbackState = null 274 cancel?.run() 275 cancel = null 276 _data = _data.copy(enabled = false) 277 } 278 279 /** Call to clean up any resources. */ 280 @AnyThread 281 fun onDestroy() = 282 bgExecutor.execute { 283 controller = null 284 playbackState = null 285 cancel?.run() 286 cancel = null 287 scrubbingChangeListener = null 288 enabledChangeListener = null 289 } 290 291 @WorkerThread 292 private fun checkPlaybackPosition() { 293 val duration = _data.duration ?: -1 294 val currentPosition = playbackState?.computePosition(duration.toLong())?.toInt() 295 if (currentPosition != null && _data.elapsedTime != currentPosition) { 296 _data = _data.copy(elapsedTime = currentPosition) 297 } 298 } 299 300 @WorkerThread 301 private fun checkIfPollingNeeded() { 302 val needed = listening && !scrubbing && playbackState?.isInMotion() ?: false 303 val traceCookie = controller?.sessionToken.hashCode() 304 if (needed) { 305 if (cancel == null) { 306 Trace.beginAsyncSection(TRACE_POSITION_NAME, traceCookie) 307 val cancelPolling = 308 bgExecutor.executeRepeatedly( 309 this::checkPlaybackPosition, 310 0L, 311 POSITION_UPDATE_INTERVAL_MILLIS 312 ) 313 cancel = Runnable { 314 cancelPolling.run() 315 Trace.endAsyncSection(TRACE_POSITION_NAME, traceCookie) 316 } 317 } 318 } else { 319 cancel?.run() 320 cancel = null 321 } 322 } 323 324 /** Gets a listener to attach to the seek bar to handle seeking. */ 325 val seekBarListener: SeekBar.OnSeekBarChangeListener 326 get() { 327 return SeekBarChangeListener(this, falsingManager) 328 } 329 330 /** first and last motion events of seekbar grab. */ 331 @VisibleForTesting var firstMotionEvent: MotionEvent? = null 332 @VisibleForTesting var lastMotionEvent: MotionEvent? = null 333 334 /** Attach touch handlers to the seek bar view. */ 335 fun attachTouchHandlers(bar: SeekBar) { 336 bar.setOnSeekBarChangeListener(seekBarListener) 337 bar.setOnTouchListener(SeekBarTouchListener(this, bar)) 338 } 339 340 fun setScrubbingChangeListener(listener: ScrubbingChangeListener) { 341 scrubbingChangeListener = listener 342 } 343 344 fun removeScrubbingChangeListener(listener: ScrubbingChangeListener) { 345 if (listener == scrubbingChangeListener) { 346 scrubbingChangeListener = null 347 } 348 } 349 350 fun setEnabledChangeListener(listener: EnabledChangeListener) { 351 enabledChangeListener = listener 352 } 353 354 fun removeEnabledChangeListener(listener: EnabledChangeListener) { 355 if (listener == enabledChangeListener) { 356 enabledChangeListener = null 357 } 358 } 359 360 /** 361 * This method specifies if user made a bad seekbar grab or not. If the vertical distance from 362 * first touch on seekbar is more than the horizontal distance, this means that the seekbar grab 363 * is more vertical and should be rejected. Seekbar accepts horizontal grabs only. 364 * 365 * Single tap has the same first and last motion event, it is counted as a valid grab. 366 * 367 * @return whether the touch on seekbar is valid. 368 */ 369 private fun isValidSeekbarGrab(): Boolean { 370 if (firstMotionEvent == null || lastMotionEvent == null) { 371 return true 372 } 373 return abs(firstMotionEvent!!.x - lastMotionEvent!!.x) >= 374 abs(firstMotionEvent!!.y - lastMotionEvent!!.y) 375 } 376 377 /** Listener interface to be notified when the user starts or stops scrubbing. */ 378 interface ScrubbingChangeListener { 379 fun onScrubbingChanged(scrubbing: Boolean) 380 } 381 382 /** Listener interface to be notified when the seekbar's enabled status changes. */ 383 interface EnabledChangeListener { 384 fun onEnabledChanged(enabled: Boolean) 385 } 386 387 private class SeekBarChangeListener( 388 val viewModel: SeekBarViewModel, 389 val falsingManager: FalsingManager, 390 ) : SeekBar.OnSeekBarChangeListener { 391 override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) { 392 if (fromUser) { 393 viewModel.onSeekProgress(progress.toLong()) 394 } 395 } 396 397 override fun onStartTrackingTouch(bar: SeekBar) { 398 viewModel.onSeekStarting() 399 } 400 401 override fun onStopTrackingTouch(bar: SeekBar) { 402 if (!viewModel.isValidSeekbarGrab() || falsingManager.isFalseTouch(MEDIA_SEEKBAR)) { 403 viewModel.onSeekFalse() 404 } 405 viewModel.onSeek(bar.progress.toLong()) 406 } 407 } 408 409 /** 410 * Responsible for intercepting touch events before they reach the seek bar. 411 * 412 * This reduces the gestures seen by the seek bar so that users don't accidentially seek when 413 * they intend to scroll the carousel. 414 */ 415 private class SeekBarTouchListener( 416 private val viewModel: SeekBarViewModel, 417 private val bar: SeekBar, 418 ) : View.OnTouchListener, GestureDetector.OnGestureListener { 419 420 // Gesture detector helps decide which touch events to intercept. 421 private val detector = GestureDetectorCompat(bar.context, this) 422 // Velocity threshold used to decide when a fling is considered a false gesture. 423 private val flingVelocity: Int = 424 ViewConfiguration.get(bar.context).run { 425 getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_SCALE_FACTOR 426 } 427 // Indicates if the gesture should go to the seek bar or if it should be intercepted. 428 private var shouldGoToSeekBar = false 429 430 /** 431 * Decide which touch events to intercept before they reach the seek bar. 432 * 433 * Based on the gesture detected, we decide whether we want the event to reach the seek bar. 434 * If we want the seek bar to see the event, then we return false so that the event isn't 435 * handled here and it will be passed along. If, however, we don't want the seek bar to see 436 * the event, then return true so that the event is handled here. 437 * 438 * When the seek bar is contained in the carousel, the carousel still has the ability to 439 * intercept the touch event. So, even though we may handle the event here, the carousel can 440 * still intercept the event. This way, gestures that we consider falses on the seek bar can 441 * still be used by the carousel for paging. 442 * 443 * Returns true for events that we don't want dispatched to the seek bar. 444 */ 445 override fun onTouch(view: View, event: MotionEvent): Boolean { 446 if (view != bar) { 447 return false 448 } 449 detector.onTouchEvent(event) 450 // Store the last motion event done on seekbar. 451 viewModel.lastMotionEvent = event.copy() 452 return !shouldGoToSeekBar 453 } 454 455 /** 456 * Handle down events that press down on the thumb. 457 * 458 * On the down action, determine a target box around the thumb to know when a scroll gesture 459 * starts by clicking on the thumb. The target box will be used by subsequent onScroll 460 * events. 461 * 462 * Returns true when the down event hits within the target box of the thumb. 463 */ 464 override fun onDown(event: MotionEvent): Boolean { 465 val padL = bar.paddingLeft 466 val padR = bar.paddingRight 467 // Compute the X location of the thumb as a function of the seek bar progress. 468 // TODO: account for thumb offset 469 val progress = bar.getProgress() 470 val range = bar.max - bar.min 471 val widthFraction = 472 if (range > 0) { 473 (progress - bar.min).toDouble() / range 474 } else { 475 0.0 476 } 477 val availableWidth = bar.width - padL - padR 478 val thumbX = 479 if (bar.isLayoutRtl()) { 480 padL + availableWidth * (1 - widthFraction) 481 } else { 482 padL + availableWidth * widthFraction 483 } 484 // Set the min, max boundaries of the thumb box. 485 // I'm cheating by using the height of the seek bar as the width of the box. 486 val halfHeight: Int = bar.height / 2 487 val targetBoxMinX = (Math.round(thumbX) - halfHeight).toInt() 488 val targetBoxMaxX = (Math.round(thumbX) + halfHeight).toInt() 489 // If the x position of the down event is within the box, then request that the parent 490 // not intercept the event. 491 val x = Math.round(event.x) 492 shouldGoToSeekBar = x >= targetBoxMinX && x <= targetBoxMaxX 493 if (shouldGoToSeekBar) { 494 bar.parent?.requestDisallowInterceptTouchEvent(true) 495 } 496 // Store the first motion event done on seekbar. 497 viewModel.firstMotionEvent = event.copy() 498 return shouldGoToSeekBar 499 } 500 501 /** 502 * Always handle single tap up. 503 * 504 * This enables the user to single tap anywhere on the seek bar to seek to that position. 505 */ 506 override fun onSingleTapUp(event: MotionEvent): Boolean { 507 shouldGoToSeekBar = true 508 return true 509 } 510 511 /** 512 * Handle scroll events when the down event is on the thumb. 513 * 514 * Returns true when the down event of the scroll hits within the target box of the thumb. 515 */ 516 override fun onScroll( 517 eventStart: MotionEvent?, 518 event: MotionEvent, 519 distanceX: Float, 520 distanceY: Float 521 ): Boolean { 522 return shouldGoToSeekBar 523 } 524 525 /** 526 * Handle fling events when the down event is on the thumb. 527 * 528 * Gestures that include a fling are considered a false gesture on the seek bar. 529 */ 530 override fun onFling( 531 eventStart: MotionEvent?, 532 event: MotionEvent, 533 velocityX: Float, 534 velocityY: Float 535 ): Boolean { 536 if (Math.abs(velocityX) > flingVelocity || Math.abs(velocityY) > flingVelocity) { 537 viewModel.onSeekFalse() 538 } 539 return shouldGoToSeekBar 540 } 541 542 override fun onShowPress(event: MotionEvent) {} 543 544 override fun onLongPress(event: MotionEvent) {} 545 } 546 547 /** State seen by seek bar UI. */ 548 data class Progress( 549 val enabled: Boolean, 550 val seekAvailable: Boolean, 551 val playing: Boolean, 552 val scrubbing: Boolean, 553 val elapsedTime: Int?, 554 val duration: Int 555 ) 556 } 557