| /* |
| * 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? |
| ) |
| } |