diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java index b954bc8fdf..5639743eaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -439,7 +439,7 @@ public class ThumbnailView extends FrameLayout { .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .transition(withCrossFade()), fit); - boolean doNotShowMissingThumbnailImage = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation() == 0; + boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23; if (slide.isInProgress() || doNotShowMissingThumbnailImage) return request; else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index 600aefb7e6..13fe2ca7bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.util.EarlyMessageCache; import org.thoughtcrime.securesms.util.FrameRateTracker; import org.thoughtcrime.securesms.util.Hex; import org.thoughtcrime.securesms.util.IasKeyStore; +import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool; import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache; import org.whispersystems.signalservice.api.KeyBackupService; import org.whispersystems.signalservice.api.SignalServiceAccountManager; @@ -99,7 +100,8 @@ public class ApplicationDependencies { private static volatile TextSecureSessionStore sessionStore; private static volatile TextSecurePreKeyStore preKeyStore; private static volatile SignalSenderKeyStore senderKeyStore; - private static volatile GiphyMp4Cache giphyMp4Cache; + private static volatile GiphyMp4Cache giphyMp4Cache; + private static volatile SimpleExoPlayerPool exoPlayerPool; @MainThread public static void init(@NonNull Application application, @NonNull Provider provider) { @@ -564,6 +566,17 @@ public class ApplicationDependencies { return giphyMp4Cache; } + public static @NonNull SimpleExoPlayerPool getExoPlayerPool() { + if (exoPlayerPool == null) { + synchronized (LOCK) { + if (exoPlayerPool == null) { + exoPlayerPool = provider.provideExoPlayerPool(); + } + } + } + return exoPlayerPool; + } + public interface Provider { @NonNull GroupsV2Operations provideGroupsV2Operations(); @NonNull SignalServiceAccountManager provideSignalServiceAccountManager(); @@ -597,5 +610,6 @@ public class ApplicationDependencies { @NonNull TextSecurePreKeyStore providePreKeyStore(); @NonNull SignalSenderKeyStore provideSenderKeyStore(); @NonNull GiphyMp4Cache provideGiphyMp4Cache(); + @NonNull SimpleExoPlayerPool provideExoPlayerPool(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 23a5d80534..7cc5a30275 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -58,6 +58,7 @@ import org.thoughtcrime.securesms.util.EarlyMessageCache; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FrameRateTracker; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool; import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceAccountManager; @@ -289,6 +290,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr return new GiphyMp4Cache(ByteUnit.MEGABYTES.toBytes(16)); } + @Override + public @NonNull SimpleExoPlayerPool provideExoPlayerPool() { + return new SimpleExoPlayerPool(context); + } + private @NonNull WebSocketFactory provideWebSocketFactory(@NonNull SignalWebSocketHealthMonitor healthMonitor) { return new WebSocketFactory() { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ExoPlayerProvider.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ExoPlayerProvider.java deleted file mode 100644 index 0cc28dc62f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ExoPlayerProvider.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.thoughtcrime.securesms.giph.mp4; - -import android.content.Context; - -import androidx.annotation.MainThread; -import androidx.annotation.NonNull; -import androidx.lifecycle.DefaultLifecycleObserver; - -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.source.MediaSourceFactory; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.upstream.DataSource; - -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.net.ContentProxySelector; -import org.thoughtcrime.securesms.video.exo.SignalDataSource; - -import okhttp3.OkHttpClient; - -/** - * Provider which creates ExoPlayer instances for displaying Giphy content. - */ -final class GiphyMp4ExoPlayerProvider implements DefaultLifecycleObserver { - - private final Context context; - private final OkHttpClient okHttpClient = ApplicationDependencies.getOkHttpClient().newBuilder().proxySelector(new ContentProxySelector()).build(); - private final DataSource.Factory dataSourceFactory = new SignalDataSource.Factory(ApplicationDependencies.getApplication(), okHttpClient, null); - private final MediaSourceFactory mediaSourceFactory = new ProgressiveMediaSource.Factory(dataSourceFactory); - - GiphyMp4ExoPlayerProvider(@NonNull Context context) { - this.context = context; - } - - @MainThread final @NonNull ExoPlayer create() { - SimpleExoPlayer exoPlayer = new SimpleExoPlayer.Builder(context) - .setMediaSourceFactory(mediaSourceFactory) - .build(); - - exoPlayer.setRepeatMode(Player.REPEAT_MODE_ALL); - exoPlayer.setVolume(0f); - - return exoPlayer; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicy.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicy.java index 91852906fb..3385aa9b18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicy.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicy.java @@ -1,11 +1,5 @@ package org.thoughtcrime.securesms.giph.mp4; -import android.os.Build; - -import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; -import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; -import com.google.android.exoplayer2.util.MimeTypes; - import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.util.DeviceProperties; import org.thoughtcrime.securesms.util.FeatureFlags; @@ -17,10 +11,6 @@ import java.util.concurrent.TimeUnit; */ public final class GiphyMp4PlaybackPolicy { - private static final int MAXIMUM_SUPPORTED_PLAYBACK_PRE_23 = 6; - private static final int MAXIMUM_SUPPORTED_PLAYBACK_PRE_23_LOW_MEM = 3; - private static final float SEARCH_RESULT_RATIO = 0.75f; - private GiphyMp4PlaybackPolicy() { } public static boolean sendAsMp4() { @@ -39,35 +29,11 @@ public final class GiphyMp4PlaybackPolicy { return TimeUnit.SECONDS.toMillis(8); } - public static int maxSimultaneousPlaybackInConversation() { - return Build.VERSION.SDK_INT >= 23 ? maxSimultaneousPlaybackWithRatio(1f - SEARCH_RESULT_RATIO) : 0; - } - public static int maxSimultaneousPlaybackInSearchResults() { - return maxSimultaneousPlaybackWithRatio(SEARCH_RESULT_RATIO); + return 12; } - private static int maxSimultaneousPlaybackWithRatio(float ratio) { - int maxInstances = 0; - - try { - MediaCodecInfo info = MediaCodecUtil.getDecoderInfo(MimeTypes.VIDEO_H264, false, false); - - if (info != null && info.getMaxSupportedInstances() > 0) { - maxInstances = (int) (info.getMaxSupportedInstances() * ratio); - } - - } catch (MediaCodecUtil.DecoderQueryException ignored) { - } - - if (maxInstances > 0) { - return maxInstances; - } - - if (DeviceProperties.isLowMemoryDevice(ApplicationDependencies.getApplication())) { - return (int) (MAXIMUM_SUPPORTED_PLAYBACK_PRE_23_LOW_MEM * ratio); - } else { - return (int) (MAXIMUM_SUPPORTED_PLAYBACK_PRE_23 * ratio); - } + public static int maxSimultaneousPlaybackInConversation() { + return 4; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java index a687163de6..2be51bc28f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java @@ -8,15 +8,20 @@ import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.util.Projection; +import org.thoughtcrime.securesms.video.exo.ExoPlayerKt; import java.util.ArrayList; import java.util.List; @@ -24,7 +29,9 @@ import java.util.List; /** * Object which holds on to an injected video player. */ -public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener { +public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener, DefaultLifecycleObserver { + private static final String TAG = Log.tag(GiphyMp4ProjectionPlayerHolder.class); + private final FrameLayout container; private final GiphyMp4VideoPlayer player; @@ -45,6 +52,20 @@ public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener { this.mediaItem = mediaItem; this.policyEnforcer = policyEnforcer; + if (player.getExoPlayer() == null) { + SimpleExoPlayer fromPool = ApplicationDependencies.getExoPlayerPool().get(); + + if (fromPool == null) { + Log.i(TAG, "Could not get exoplayer from pool."); + return; + } else { + ExoPlayerKt.configureForGifPlayback(fromPool); + fromPool.addListener(this); + } + + player.setExoPlayer(fromPool); + } + player.setVideoItem(mediaItem); player.play(); } @@ -52,7 +73,14 @@ public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener { public void clearMedia() { this.mediaItem = null; this.policyEnforcer = null; - player.stop(); + + SimpleExoPlayer exoPlayer = player.getExoPlayer(); + if (exoPlayer != null) { + player.stop(); + player.setExoPlayer(null); + exoPlayer.removeListener(this); + ApplicationDependencies.getExoPlayerPool().pool(exoPlayer); + } } public @Nullable MediaItem getMediaItem() { @@ -98,25 +126,46 @@ public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener { } } + @Override + public void onResume(@NonNull LifecycleOwner owner) { + if (mediaItem != null) { + SimpleExoPlayer fromPool = ApplicationDependencies.getExoPlayerPool().get(); + if (fromPool != null) { + ExoPlayerKt.configureForGifPlayback(fromPool); + fromPool.addListener(this); + player.setExoPlayer(fromPool); + player.setVideoItem(mediaItem); + player.play(); + } + } + } + + @Override + public void onPause(@NonNull LifecycleOwner owner) { + if (player.getExoPlayer() != null) { + player.getExoPlayer().stop(); + player.getExoPlayer().clearMediaItems(); + player.getExoPlayer().removeListener(this); + ApplicationDependencies.getExoPlayerPool().pool(player.getExoPlayer()); + player.setExoPlayer(null); + } + } + public static @NonNull List injectVideoViews(@NonNull Context context, @NonNull Lifecycle lifecycle, @NonNull ViewGroup viewGroup, int nPlayers) { - List holders = new ArrayList<>(nPlayers); - GiphyMp4ExoPlayerProvider playerProvider = new GiphyMp4ExoPlayerProvider(context); + List holders = new ArrayList<>(nPlayers); for (int i = 0; i < nPlayers; i++) { FrameLayout container = (FrameLayout) LayoutInflater.from(context) .inflate(R.layout.giphy_mp4_player, viewGroup, false); - GiphyMp4VideoPlayer player = container.findViewById(R.id.video_player); - ExoPlayer exoPlayer = playerProvider.create(); - GiphyMp4ProjectionPlayerHolder holder = new GiphyMp4ProjectionPlayerHolder(container, player); + GiphyMp4VideoPlayer player = container.findViewById(R.id.video_player); + GiphyMp4ProjectionPlayerHolder holder = new GiphyMp4ProjectionPlayerHolder(container, player); - lifecycle.addObserver(player); - player.setExoPlayer(exoPlayer); + lifecycle.addObserver(holder); player.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FILL); - exoPlayer.addListener(holder); holders.add(holder); viewGroup.addView(container); diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java index 1bcf671a2f..38b49a655b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java @@ -13,13 +13,16 @@ import androidx.lifecycle.LifecycleOwner; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.ui.PlayerView; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.CornerMask; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.util.Projection; +import org.thoughtcrime.securesms.video.exo.ExoPlayerKt; /** * Video Player class specifically created for the GiphyMp4Fragment. @@ -29,9 +32,10 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif @SuppressWarnings("unused") private static final String TAG = Log.tag(GiphyMp4VideoPlayer.class); - private final PlayerView exoView; - private ExoPlayer exoPlayer; - private CornerMask cornerMask; + private final PlayerView exoView; + private SimpleExoPlayer exoPlayer; + private CornerMask cornerMask; + private MediaItem mediaItem; public GiphyMp4VideoPlayer(Context context) { this(context, null); @@ -64,16 +68,25 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif } } - void setExoPlayer(@NonNull ExoPlayer exoPlayer) { + @Nullable SimpleExoPlayer getExoPlayer() { + return exoPlayer; + } + + void setExoPlayer(@Nullable SimpleExoPlayer exoPlayer) { exoView.setPlayer(exoPlayer); this.exoPlayer = exoPlayer; } int getPlaybackState() { - return exoPlayer.getPlaybackState(); + if (exoPlayer != null) { + return exoPlayer.getPlaybackState(); + } else { + return -1; + } } void setVideoItem(@NonNull MediaItem mediaItem) { + this.mediaItem = mediaItem; exoPlayer.setMediaItem(mediaItem); exoPlayer.prepare(); } @@ -98,6 +111,7 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif if (exoPlayer != null) { exoPlayer.stop(); exoPlayer.clearMediaItems(); + mediaItem = null; } } @@ -112,11 +126,4 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif void setResizeMode(@AspectRatioFrameLayout.ResizeMode int resizeMode) { exoView.setResizeMode(resizeMode); } - - @Override - public void onDestroy(@NonNull LifecycleOwner owner) { - if (exoPlayer != null) { - exoPlayer.release(); - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java index c1b01b48b2..9b6cb775c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java @@ -19,6 +19,7 @@ package org.thoughtcrime.securesms.mms; import android.content.Context; import android.content.res.Resources.Theme; import android.net.Uri; +import android.os.Build; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; @@ -55,7 +56,7 @@ public class VideoSlide extends Slide { @Override public boolean hasPlayOverlay() { - return !(isVideoGif() && GiphyMp4PlaybackPolicy.autoplay()) || GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation() == 0; + return !(isVideoGif() && GiphyMp4PlaybackPolicy.autoplay()) || Build.VERSION.SDK_INT < 23; } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java index cbc8b02c8f..1d52f69e4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -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; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/ExoPlayer.kt b/app/src/main/java/org/thoughtcrime/securesms/video/exo/ExoPlayer.kt new file mode 100644 index 0000000000..1901c0844a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/ExoPlayer.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt new file mode 100644 index 0000000000..b04bbe1cf6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt @@ -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(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( + private val maximumReservedPlayers: Int, +) : AppForegroundObserver.Listener { + + private val pool: MutableMap = 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 + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/video/exo/ExoPlayerPoolTest.kt b/app/src/test/java/org/thoughtcrime/securesms/video/exo/ExoPlayerPoolTest.kt new file mode 100644 index 0000000000..52627a366a --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/video/exo/ExoPlayerPoolTest.kt @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.video.exo + +import com.google.android.exoplayer2.ExoPlayer +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mockito.mock + +@RunWith(JUnit4::class) +class ExoPlayerPoolTest { + + @Test + fun `Given an empty pool, when I require a player, then I expect a player`() { + // GIVEN + val testSubject = createTestSubject(1, 1) + + // WHEN + val player = testSubject.require() + + // THEN + assertNotNull(player) + } + + @Test(expected = IllegalStateException::class) + fun `Given a pool without available players, when I require a player, then I expect an exception`() { + // GIVEN + val testSubject = createTestSubject(1, 0) + + // WHEN + testSubject.require() + + // THEN + fail("Expected an IllegalStateException") + } + + @Test + fun `Given a pool that allows 10 unreserved items, when I ask for 20, then I expect 10 items and 10 nulls`() { + // GIVEN + val testSubject = createTestSubject(0, 10) + + // WHEN + val players = (1..10).map { testSubject.get() } + val nulls = (1..10).map { testSubject.get() } + + // THEN + assertTrue(players.all { it != null }) + assertTrue(nulls.all { it == null }) + } + + @Test + fun `Given a pool that allows 10 items and has all items checked out, when I return then check them all out again, then I expect 10 non null players`() { + // GIVEN + val testSubject = createTestSubject(0, 10) + val players = (1..10).map { testSubject.get() } + + // WHEN + players.filterNotNull().forEach { testSubject.pool(it) } + val morePlayers = (1..10).map { testSubject.get() } + + assertTrue(morePlayers.all { it != null }) + } + + @Test(expected = IllegalArgumentException::class) + fun `Given an ExoPlayer not in the pool, when I pool it, then I expect an IllegalArgumentException`() { + // GIVEN + val player = mock(ExoPlayer::class.java) + val pool = createTestSubject(1, 10) + + // WHEN + pool.pool(player) + + // THEN + fail("Expected an IllegalArgumentException to be thrown") + } + + private fun createTestSubject( + maximumReservedPlayers: Int, + maximumSimultaneousPlayback: Int + ): ExoPlayerPool { + return object : ExoPlayerPool(maximumReservedPlayers) { + override fun createPlayer(): ExoPlayer { + return mock(ExoPlayer::class.java) + } + + override fun getMaxSimultaneousPlayback(): Int { + return maximumSimultaneousPlayback + } + } + } +}