blob: 63a361de4d87d41ee9e1d7133fa80311660a86e2 [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.session.MediaController
import android.media.session.PlaybackState
import android.os.SystemProperties
import android.util.Log
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState
import com.android.systemui.util.concurrency.DelayableExecutor
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
private const val DEBUG = true
private const val TAG = "MediaTimeout"
private val PAUSED_MEDIA_TIMEOUT = SystemProperties
.getLong("debug.sysui.media_timeout", TimeUnit.MINUTES.toMillis(10))
/**
* Controller responsible for keeping track of playback states and expiring inactive streams.
*/
@Singleton
class MediaTimeoutListener @Inject constructor(
private val mediaControllerFactory: MediaControllerFactory,
@Main private val mainExecutor: DelayableExecutor
) : MediaDataManager.Listener {
private val mediaListeners: MutableMap<String, PlaybackStateListener> = mutableMapOf()
/**
* Callback representing that a media object is now expired:
* @param token Media session unique identifier
* @param pauseTimeout True when expired for {@code PAUSED_MEDIA_TIMEOUT}
*/
lateinit var timeoutCallback: (String, Boolean) -> Unit
override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
if (mediaListeners.containsKey(key)) {
return
}
// Having an old key means that we're migrating from/to resumption. We should update
// the old listener to make sure that events will be dispatched to the new location.
val migrating = oldKey != null && key != oldKey
if (migrating) {
val reusedListener = mediaListeners.remove(oldKey)
if (reusedListener != null) {
val wasPlaying = reusedListener.playing ?: false
if (DEBUG) Log.d(TAG, "migrating key $oldKey to $key, for resumption")
reusedListener.mediaData = data
reusedListener.key = key
mediaListeners[key] = reusedListener
if (wasPlaying != reusedListener.playing) {
// If a player becomes active because of a migration, we'll need to broadcast
// its state. Doing it now would lead to reentrant callbacks, so let's wait
// until we're done.
mainExecutor.execute {
if (mediaListeners[key]?.playing == true) {
if (DEBUG) Log.d(TAG, "deliver delayed playback state for $key")
timeoutCallback.invoke(key, false /* timedOut */)
}
}
}
return
} else {
Log.w(TAG, "Old key $oldKey for player $key doesn't exist. Continuing...")
}
}
mediaListeners[key] = PlaybackStateListener(key, data)
}
override fun onMediaDataRemoved(key: String) {
mediaListeners.remove(key)?.destroy()
}
fun isTimedOut(key: String): Boolean {
return mediaListeners[key]?.timedOut ?: false
}
private inner class PlaybackStateListener(
var key: String,
data: MediaData
) : MediaController.Callback() {
var timedOut = false
var playing: Boolean? = null
var mediaData: MediaData = data
set(value) {
mediaController?.unregisterCallback(this)
field = value
mediaController = if (field.token != null) {
mediaControllerFactory.create(field.token)
} else {
null
}
mediaController?.registerCallback(this)
// Let's register the cancellations, but not dispatch events now.
// Timeouts didn't happen yet and reentrant events are troublesome.
processState(mediaController?.playbackState, dispatchEvents = false)
}
// Resume controls may have null token
private var mediaController: MediaController? = null
private var cancellation: Runnable? = null
init {
mediaData = data
}
fun destroy() {
mediaController?.unregisterCallback(this)
cancellation?.run()
}
override fun onPlaybackStateChanged(state: PlaybackState?) {
processState(state, dispatchEvents = true)
}
private fun processState(state: PlaybackState?, dispatchEvents: Boolean) {
if (DEBUG) {
Log.v(TAG, "processState: $state")
}
val isPlaying = state != null && isPlayingState(state.state)
if (playing == isPlaying && playing != null) {
return
}
playing = isPlaying
if (!isPlaying) {
if (DEBUG) {
Log.v(TAG, "schedule timeout for $key")
}
if (cancellation != null) {
if (DEBUG) Log.d(TAG, "cancellation already exists, continuing.")
return
}
expireMediaTimeout(key, "PLAYBACK STATE CHANGED - $state")
cancellation = mainExecutor.executeDelayed({
cancellation = null
if (DEBUG) {
Log.v(TAG, "Execute timeout for $key")
}
timedOut = true
// this event is async, so it's safe even when `dispatchEvents` is false
timeoutCallback(key, timedOut)
}, PAUSED_MEDIA_TIMEOUT)
} else {
expireMediaTimeout(key, "playback started - $state, $key")
timedOut = false
if (dispatchEvents) {
timeoutCallback(key, timedOut)
}
}
}
private fun expireMediaTimeout(mediaKey: String, reason: String) {
cancellation?.apply {
if (DEBUG) {
Log.v(TAG,
"media timeout cancelled for $mediaKey, reason: $reason")
}
run()
}
cancellation = null
}
}
}