blob: efc476d0c86f41d307f4cdcf22955136a5f4fe44 [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.media
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.PlaybackState
import android.os.SystemClock
import android.view.MotionEvent
import android.view.View
import android.widget.SeekBar
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.LiveData
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.util.concurrency.RepeatableExecutor
import java.util.concurrent.Executor
import javax.inject.Inject
private const val POSITION_UPDATE_INTERVAL_MILLIS = 100L
private fun PlaybackState.isInMotion(): Boolean {
return this.state == PlaybackState.STATE_PLAYING ||
this.state == PlaybackState.STATE_FAST_FORWARDING ||
this.state == PlaybackState.STATE_REWINDING
}
/**
* Gets the playback position while accounting for the time since the [PlaybackState] was last
* retrieved.
*
* This method closely follows the implementation of
* [MediaSessionRecord#getStateWithUpdatedPosition].
*/
private fun PlaybackState.computePosition(duration: Long): Long {
var currentPosition = this.position
if (this.isInMotion()) {
val updateTime = this.getLastPositionUpdateTime()
val currentTime = SystemClock.elapsedRealtime()
if (updateTime > 0) {
var position = (this.playbackSpeed * (currentTime - updateTime)).toLong() +
this.getPosition()
if (duration >= 0 && position > duration) {
position = duration.toLong()
} else if (position < 0) {
position = 0
}
currentPosition = position
}
}
return currentPosition
}
/** ViewModel for seek bar in QS media player. */
class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: RepeatableExecutor) {
private var _data = Progress(false, false, null, null)
set(value) {
field = value
_progress.postValue(value)
}
private val _progress = MutableLiveData<Progress>().apply {
postValue(_data)
}
val progress: LiveData<Progress>
get() = _progress
private var controller: MediaController? = null
set(value) {
if (field?.sessionToken != value?.sessionToken) {
field?.unregisterCallback(callback)
value?.registerCallback(callback)
field = value
}
}
private var playbackState: PlaybackState? = null
private var callback = object : MediaController.Callback() {
override fun onPlaybackStateChanged(state: PlaybackState) {
playbackState = state
if (PlaybackState.STATE_NONE.equals(playbackState)) {
clearController()
} else {
checkIfPollingNeeded()
}
}
override fun onSessionDestroyed() {
clearController()
}
}
private var cancel: Runnable? = null
/** Listening state (QS open or closed) is used to control polling of progress. */
var listening = true
set(value) = bgExecutor.execute {
if (field != value) {
field = value
checkIfPollingNeeded()
}
}
/**
* Handle request to change the current position in the media track.
* @param position Place to seek to in the track.
*/
@WorkerThread
fun onSeek(position: Long) {
controller?.transportControls?.seekTo(position)
// Invalidate the cached playbackState to avoid the thumb jumping back to the previous
// position.
playbackState = null
}
/**
* Updates media information.
* @param mediaController controller for media session
*/
@WorkerThread
fun updateController(mediaController: MediaController?) {
controller = mediaController
playbackState = controller?.playbackState
val mediaMetadata = controller?.metadata
val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L
val position = playbackState?.position?.toInt()
val duration = mediaMetadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt()
val enabled = if (playbackState == null ||
playbackState?.getState() == PlaybackState.STATE_NONE ||
(duration != null && duration <= 0)) false else true
_data = Progress(enabled, seekAvailable, position, duration)
checkIfPollingNeeded()
}
/**
* Puts the seek bar into a resumption state.
*
* This should be called when the media session behind the controller has been destroyed.
*/
@AnyThread
fun clearController() = bgExecutor.execute {
controller = null
playbackState = null
cancel?.run()
cancel = null
_data = _data.copy(enabled = false)
}
/**
* Call to clean up any resources.
*/
@AnyThread
fun onDestroy() = bgExecutor.execute {
controller = null
playbackState = null
cancel?.run()
cancel = null
}
@WorkerThread
private fun checkPlaybackPosition() {
val duration = _data.duration ?: -1
val currentPosition = playbackState?.computePosition(duration.toLong())?.toInt()
if (currentPosition != null && _data.elapsedTime != currentPosition) {
_data = _data.copy(elapsedTime = currentPosition)
}
}
@WorkerThread
private fun checkIfPollingNeeded() {
val needed = listening && playbackState?.isInMotion() ?: false
if (needed) {
if (cancel == null) {
cancel = bgExecutor.executeRepeatedly(this::checkPlaybackPosition, 0L,
POSITION_UPDATE_INTERVAL_MILLIS)
}
} else {
cancel?.run()
cancel = null
}
}
/** Gets a listener to attach to the seek bar to handle seeking. */
val seekBarListener: SeekBar.OnSeekBarChangeListener
get() {
return SeekBarChangeListener(this, bgExecutor)
}
/** Gets a listener to attach to the seek bar to disable touch intercepting. */
val seekBarTouchListener: View.OnTouchListener
get() {
return SeekBarTouchListener()
}
private class SeekBarChangeListener(
val viewModel: SeekBarViewModel,
val bgExecutor: Executor
) : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
bgExecutor.execute {
viewModel.onSeek(progress.toLong())
}
}
}
override fun onStartTrackingTouch(bar: SeekBar) {
}
override fun onStopTrackingTouch(bar: SeekBar) {
val pos = bar.progress.toLong()
bgExecutor.execute {
viewModel.onSeek(pos)
}
}
}
private class SeekBarTouchListener : View.OnTouchListener {
override fun onTouch(view: View, event: MotionEvent): Boolean {
view.parent.requestDisallowInterceptTouchEvent(true)
return view.onTouchEvent(event)
}
}
/** State seen by seek bar UI. */
data class Progress(
val enabled: Boolean,
val seekAvailable: Boolean,
val elapsedTime: Int?,
val duration: Int?
)
}