Implement ExoPlayerPool for better reuse and performance.

This commit is contained in:
Alex Hart
2021-09-24 13:10:48 -03:00
committed by GitHub
parent a5c51ff801
commit 5c1b57e4ba
12 changed files with 478 additions and 154 deletions

View File

@@ -30,18 +30,14 @@ import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
import com.google.android.exoplayer2.source.MediaSourceFactory;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import com.google.android.exoplayer2.ui.PlayerControlView;
import com.google.android.exoplayer2.ui.PlayerView;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.video.exo.SignalDataSource;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@@ -61,6 +57,8 @@ public class VideoPlayer extends FrameLayout {
private PlayerCallback playerCallback;
private boolean clipped;
private long clippedStartUs;
private ExoPlayerListener exoPlayerListener;
private Player.Listener playerListener;
public VideoPlayer(Context context) {
this(context, null);
@@ -78,51 +76,49 @@ public class VideoPlayer extends FrameLayout {
this.exoView = findViewById(R.id.video_view);
this.exoControls = new PlayerControlView(getContext());
this.exoControls.setShowTimeoutMs(-1);
this.exoPlayerListener = new ExoPlayerListener(this, window, playerStateCallback, playerPositionDiscontinuityCallback);
this.playerListener = new Player.Listener() {
@Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
onPlaybackStateChanged(playWhenReady, exoPlayer.getPlaybackState());
}
@Override
public void onPlaybackStateChanged(int playbackState) {
onPlaybackStateChanged(exoPlayer.getPlayWhenReady(), playbackState);
}
private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) {
if (playerCallback != null) {
switch (playbackState) {
case Player.STATE_READY:
if (playWhenReady) playerCallback.onPlaying();
break;
case Player.STATE_ENDED:
playerCallback.onStopped();
break;
}
}
}
@Override
public void onPlayerError(@NonNull PlaybackException error) {
Log.w(TAG, "A player error occurred", error);
if (playerCallback != null) {
playerCallback.onError();
}
}
};
}
private MediaItem mediaItem;
public void setVideoSource(@NonNull VideoSlide videoSource, boolean autoplay) {
Context context = getContext();
if (exoPlayer == null) {
DataSource.Factory attachmentDataSourceFactory = new SignalDataSource.Factory(context, null, null);
MediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(attachmentDataSourceFactory);
exoPlayer = new SimpleExoPlayer.Builder(context).setMediaSourceFactory(mediaSourceFactory).build();
exoPlayer.addListener(new ExoPlayerListener(this, window, playerStateCallback, playerPositionDiscontinuityCallback));
exoPlayer.addListener(new Player.Listener() {
@Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
onPlaybackStateChanged(playWhenReady, exoPlayer.getPlaybackState());
}
@Override
public void onPlaybackStateChanged(int playbackState) {
onPlaybackStateChanged(exoPlayer.getPlayWhenReady(), playbackState);
}
private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) {
if (playerCallback != null) {
switch (playbackState) {
case Player.STATE_READY:
if (playWhenReady) playerCallback.onPlaying();
break;
case Player.STATE_ENDED:
playerCallback.onStopped();
break;
}
}
}
@Override
public void onPlayerError(@NonNull PlaybackException error) {
Log.w(TAG, "A player error occurred", error);
if (playerCallback != null) {
playerCallback.onError();
}
}
});
exoPlayer = ApplicationDependencies.getExoPlayerPool().require();
exoPlayer.addListener(exoPlayerListener);
exoPlayer.addListener(playerListener);
exoView.setPlayer(exoPlayer);
exoControls.setPlayer(exoPlayer);
}
@@ -159,7 +155,16 @@ public class VideoPlayer extends FrameLayout {
public void cleanup() {
if (this.exoPlayer != null) {
this.exoPlayer.release();
exoPlayer.stop();
exoPlayer.clearMediaItems();
exoView.setPlayer(null);
exoControls.setPlayer(null);
exoPlayer.removeListener(playerListener);
exoPlayer.removeListener(exoPlayerListener);
ApplicationDependencies.getExoPlayerPool().pool(exoPlayer);
this.exoPlayer = null;
}
}

View File

@@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.video.exo
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player
fun ExoPlayer.configureForGifPlayback() {
repeatMode = Player.REPEAT_MODE_ALL
volume = 0f
}
fun ExoPlayer.configureForVideoPlayback() {
repeatMode = Player.REPEAT_MODE_OFF
volume = 1f
}

View File

@@ -0,0 +1,215 @@
package org.thoughtcrime.securesms.video.exo
import android.content.Context
import androidx.annotation.MainThread
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.mediacodec.MediaCodecUtil
import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException
import com.google.android.exoplayer2.source.MediaSourceFactory
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.util.MimeTypes
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.net.ContentProxySelector
import org.thoughtcrime.securesms.util.AppForegroundObserver
import org.thoughtcrime.securesms.util.DeviceProperties
/**
* ExoPlayerPool concrete instance which helps to manage a pool of SimpleExoPlayer objects
*/
class SimpleExoPlayerPool(context: Context) : ExoPlayerPool<SimpleExoPlayer>(MAXIMUM_RESERVED_PLAYERS) {
private val context: Context = context.applicationContext
private val okHttpClient = ApplicationDependencies.getOkHttpClient().newBuilder().proxySelector(ContentProxySelector()).build()
private val dataSourceFactory: DataSource.Factory = SignalDataSource.Factory(ApplicationDependencies.getApplication(), okHttpClient, null)
private val mediaSourceFactory: MediaSourceFactory = ProgressiveMediaSource.Factory(dataSourceFactory)
init {
ApplicationDependencies.getAppForegroundObserver().addListener(this)
}
/**
* Tries to get the max number of instances that can be played back on the screen at a time, based off of
* the device API level and decoder info.
*/
override fun getMaxSimultaneousPlayback(): Int {
val maxInstances = try {
val info = MediaCodecUtil.getDecoderInfo(MimeTypes.VIDEO_H264, false, false)
if (info != null && info.maxSupportedInstances > 0) {
info.maxSupportedInstances
} else {
0
}
} catch (ignored: DecoderQueryException) {
0
}
if (maxInstances > 0) {
return maxInstances
}
return if (DeviceProperties.isLowMemoryDevice(ApplicationDependencies.getApplication())) {
MAXIMUM_SUPPORTED_PLAYBACK_PRE_23_LOW_MEM
} else {
MAXIMUM_SUPPORTED_PLAYBACK_PRE_23
}
}
@MainThread
override fun createPlayer(): SimpleExoPlayer {
return SimpleExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.build()
}
companion object {
private const val MAXIMUM_RESERVED_PLAYERS = 1
private const val MAXIMUM_SUPPORTED_PLAYBACK_PRE_23 = 6
private const val MAXIMUM_SUPPORTED_PLAYBACK_PRE_23_LOW_MEM = 3
}
}
/**
* ExoPlayer pool which allows for the quick and efficient reuse of ExoPlayer instances instead of creating and destroying them
* as needed. This class will, if added as an AppForegroundObserver.Listener, evict players when the app is backgrounded to try to
* make sure it is a good citizen on the device.
*
* This class also supports reserving a number of players, which count against its total specified by getMaxSimultaneousPlayback. These
* players will be returned first when a player is requested via require.
*/
abstract class ExoPlayerPool<T : ExoPlayer>(
private val maximumReservedPlayers: Int,
) : AppForegroundObserver.Listener {
private val pool: MutableMap<T, PoolState> = mutableMapOf()
/**
* Try to get a player from the non-reserved pool.
*
* @return A player if one is available, otherwise null
*/
@MainThread
fun get(): T? {
return get(allowReserved = false)
}
/**
* Get a player, preferring reserved players.
*
* @return A non-null player instance. If one is not available, an exception is thrown.
* @throws IllegalStateException if no player is available.
*/
@MainThread
fun require(): T {
return checkNotNull(get(allowReserved = true)) { "Required exoPlayer could not be acquired! :: ${poolStats()}" }
}
/**
* Returns a player to the pool. If the player is not from the pool, an exception is thrown.
*
* @throws IllegalArgumentException if the player passed is not in the pool
*/
@MainThread
fun pool(exoPlayer: T) {
val poolState = pool[exoPlayer]
if (poolState != null) {
pool[exoPlayer] = poolState.copy(available = true)
} else {
throw IllegalArgumentException("Tried to return unknown ExoPlayer to pool :: ${poolStats()}")
}
}
@MainThread
private fun get(allowReserved: Boolean): T? {
val player = findAvailablePlayer(allowReserved)
return if (player == null && pool.size < getMaximumAllowed(allowReserved)) {
val newPlayer = createPlayer()
val poolState = createPoolStateForNewEntry(allowReserved)
pool[newPlayer] = poolState
newPlayer
} else if (player != null) {
val poolState = pool[player]!!.copy(available = false)
pool[player] = poolState
player
} else {
null
}
}
private fun getMaximumAllowed(allowReserved: Boolean): Int {
return if (allowReserved) getMaxSimultaneousPlayback() else getMaxSimultaneousPlayback() - maximumReservedPlayers
}
private fun createPoolStateForNewEntry(allowReserved: Boolean): PoolState {
return if (allowReserved && pool.none { (_, v) -> v.reserved }) {
PoolState(available = false, reserved = true)
} else {
PoolState(available = false, reserved = false)
}
}
private fun findAvailablePlayer(allowReserved: Boolean): T? {
return if (allowReserved) {
findFirstReservedAndAvailablePlayer() ?: findFirstUnreservedAndAvailablePlayer()
} else {
findFirstUnreservedAndAvailablePlayer()
}
}
private fun findFirstReservedAndAvailablePlayer(): T? {
return pool.filter { (_, v) -> v.reservedAndAvailable }.keys.firstOrNull()
}
private fun findFirstUnreservedAndAvailablePlayer(): T? {
return pool.filter { (_, v) -> v.unreservedAndAvailable }.keys.firstOrNull()
}
protected abstract fun createPlayer(): T
@MainThread
override fun onBackground() {
val playersToRelease = pool.filter { (_, v) -> v.available }.keys
pool -= playersToRelease
playersToRelease.forEach { it.release() }
}
private fun poolStats(): String {
val poolStats = PoolStats(
created = pool.size,
maxUnreserved = getMaxSimultaneousPlayback() - maximumReservedPlayers,
maxReserved = maximumReservedPlayers
)
pool.values.fold(poolStats) { acc, state ->
acc.copy(
unreservedAndAvailable = acc.unreservedAndAvailable + if (state.unreservedAndAvailable) 1 else 0,
reservedAndAvailable = acc.reservedAndAvailable + if (state.reservedAndAvailable) 1 else 0,
unreserved = acc.unreserved + if (!state.reserved) 1 else 0,
reserved = acc.reserved + if (state.reserved) 1 else 0
)
}
return poolStats.toString()
}
protected abstract fun getMaxSimultaneousPlayback(): Int
private data class PoolStats(
val created: Int = 0,
val maxUnreserved: Int = 0,
val maxReserved: Int = 0,
val unreservedAndAvailable: Int = 0,
val reservedAndAvailable: Int = 0,
val unreserved: Int = 0,
val reserved: Int = 0
)
private data class PoolState(
val available: Boolean,
val reserved: Boolean
) {
val unreservedAndAvailable = available && !reserved
val reservedAndAvailable = available && reserved
}
}