Move the VideoEditorFragment to the media-send feature module.

This commit is contained in:
Alex Hart
2026-06-18 16:09:12 -04:00
committed by Greyson Parrelli
parent 987f92245d
commit 83cb48d119
68 changed files with 1235 additions and 910 deletions
@@ -0,0 +1,524 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.video;
import android.content.Context;
import android.content.res.TypedArray;
import android.media.AudioAttributes;
import android.media.AudioFocusRequest;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.core.content.ContextCompat;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.analytics.AnalyticsListener;
import androidx.media3.exoplayer.source.ClippingMediaSource;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.source.LoadEventInfo;
import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.ui.AspectRatioFrameLayout;
import androidx.media3.ui.LegacyPlayerControlView;
import androidx.media3.ui.PlayerView;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.incrementalmac.InvalidMacException;
import org.signal.video.exo.ExoPlayerPool;
import java.io.IOException;
import java.util.Objects;
@OptIn(markerClass = UnstableApi.class)
public class VideoPlayer extends FrameLayout {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(VideoPlayer.class);
private final PlayerView exoView;
private final View progressBar;
private final DefaultMediaSourceFactory mediaSourceFactory;
private ExoPlayer exoPlayer;
private ExoPlayerPool<ExoPlayer> exoPlayerPool;
private LegacyPlayerControlView exoControls;
private Window window;
private PlayerStateCallback playerStateCallback;
private PlayerPositionDiscontinuityCallback playerPositionDiscontinuityCallback;
private PlayerCallback playerCallback;
private boolean clipped;
private long clippedStartUs;
private ExoPlayerListener exoPlayerListener;
private Player.Listener playerListener;
private AnalyticsListener analyticsListener;
private boolean muted;
private AudioFocusRequest audioFocusRequest;
private boolean requestAudioFocus = true;
public VideoPlayer(Context context) {
this(context, null);
}
public VideoPlayer(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public VideoPlayer(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.VideoPlayer);
int videPlayerLayout = typedArray.getResourceId(R.styleable.VideoPlayer_playerLayoutId, R.layout.video_player);
typedArray.recycle();
inflate(context, videPlayerLayout, this);
this.mediaSourceFactory = new DefaultMediaSourceFactory(context);
this.exoView = findViewById(R.id.video_view);
this.progressBar = findViewById(R.id.progress_bar);
this.exoControls = createPlayerControls(getContext());
final AudioManager audioManager = ContextCompat.getSystemService(context, AudioManager.class);
if (Build.VERSION.SDK_INT >= 26) {
audioFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(
new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.setOnAudioFocusChangeListener(focusChange -> {
})
.build();
} else {
audioFocusRequest = null;
}
this.exoPlayerListener = new ExoPlayerListener();
this.analyticsListener = new AnalyticsListener() {
@Override
public void onLoadError(EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) {
if (error instanceof InvalidMacException) {
Log.w(TAG, "Bad incremental mac!", error);
playerCallback.onError(error);
}
}
};
this.playerListener = new Player.Listener() {
@Override
public void onIsPlayingChanged(boolean isPlaying) {
if (!isPlaying && exoPlayer.getCurrentPosition() >= exoPlayer.getDuration()) {
exoPlayer.seekTo(0);
exoPlayer.setPlayWhenReady(false);
}
if (audioManager == null) {
return;
}
if (Build.VERSION.SDK_INT >= 26 && audioFocusRequest != null) {
if (isPlaying) {
if (requestAudioFocus) {
audioManager.requestAudioFocus(audioFocusRequest);
}
} else {
audioManager.abandonAudioFocusRequest(audioFocusRequest);
}
} else {
if (isPlaying) {
if (requestAudioFocus) {
audioManager.requestAudioFocus(
focusChange -> {
// Do nothing
},
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
);
}
} else {
audioManager.abandonAudioFocus(
focusChange -> {
// Do nothing
}
);
}
}
}
@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 (progressBar != null) {
if (playbackState == Player.STATE_BUFFERING) {
progressBar.setVisibility(View.VISIBLE);
} else {
progressBar.setVisibility(View.GONE);
}
}
if (playerCallback != null) {
switch (playbackState) {
case Player.STATE_READY:
playerCallback.onReady();
if (playWhenReady) {
playerCallback.onPlaying();
} else {
playerCallback.onStopped();
}
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(error);
}
}
};
}
private LegacyPlayerControlView createPlayerControls(Context context) {
final LegacyPlayerControlView playerControlView = new LegacyPlayerControlView(context);
playerControlView.setShowTimeoutMs(-1);
playerControlView.setShowNextButton(false);
playerControlView.setShowPreviousButton(false);
return playerControlView;
}
private MediaItem mediaItem;
public void setExoPlayerPool(ExoPlayerPool<ExoPlayer> exoPlayerPool) {
this.exoPlayerPool = exoPlayerPool;
}
public void setVideoSource(@NonNull Uri uri, boolean autoplay, String poolTag) {
setVideoSource(uri, autoplay, poolTag, 0, 0);
}
public void setVideoSource(@NonNull Uri uri, boolean autoplay, String poolTag, long clipStartMs, long clipEndMs) {
if (exoPlayer == null) {
exoPlayer = exoPlayerPool.require(poolTag);
exoPlayer.addListener(exoPlayerListener);
exoPlayer.addListener(playerListener);
exoPlayer.addAnalyticsListener(analyticsListener);
exoView.setPlayer(exoPlayer);
exoControls.setPlayer(exoPlayer);
if (muted) {
mute();
}
}
mediaItem = MediaItem.fromUri(Objects.requireNonNull(uri)).buildUpon()
.setClippingConfiguration(getClippingConfiguration(clipStartMs, clipEndMs))
.build();
exoPlayer.setMediaItem(mediaItem);
exoPlayer.prepare();
exoPlayer.setPlayWhenReady(autoplay);
}
public void mute() {
this.muted = true;
if (exoPlayer != null) {
exoPlayer.setVolume(0f);
}
}
public void unmute() {
this.muted = false;
if (exoPlayer != null) {
exoPlayer.setVolume(1f);
}
}
public boolean hasAudioTrack() {
if (exoPlayer != null) {
Tracks tracks = exoPlayer.getCurrentTracks();
return tracks.containsType(C.TRACK_TYPE_AUDIO);
}
return false;
}
public boolean isInitialized() {
return exoPlayer != null;
}
public void setResizeMode(@AspectRatioFrameLayout.ResizeMode int resizeMode) {
exoView.setResizeMode(resizeMode);
}
public boolean isPlaying() {
if (this.exoPlayer != null) {
return this.exoPlayer.isPlaying();
} else {
return false;
}
}
public void pause() {
if (this.exoPlayer != null) {
this.exoPlayer.setPlayWhenReady(false);
}
}
public void hideControls() {
if (this.exoView != null) {
this.exoView.hideController();
}
}
public void setKeepContentOnPlayerReset(boolean keepContentOnPlayerReset) {
if (this.exoView != null) {
this.exoView.setKeepContentOnPlayerReset(keepContentOnPlayerReset);
}
}
@Override
public void setOnClickListener(@Nullable OnClickListener l) {
if (this.exoView != null) {
this.exoView.setClickable(false);
}
super.setOnClickListener(l);
}
public @Nullable LegacyPlayerControlView getControlView() {
return this.exoControls;
}
public void setControlView(LegacyPlayerControlView controller) {
exoControls = controller;
exoControls.setPlayer(exoPlayer);
}
public void stop() {
if (this.exoPlayer != null) {
exoPlayer.stop();
exoPlayer.clearMediaItems();
}
}
public void cleanup() {
stop();
if (this.exoPlayer != null) {
exoView.setPlayer(null);
if (exoPlayer.equals(exoControls.getPlayer())) {
exoControls.setPlayer(null);
}
exoPlayer.removeListener(playerListener);
exoPlayer.removeListener(exoPlayerListener);
exoPlayerPool.pool(exoPlayer);
this.exoPlayer = null;
}
}
public void loopForever() {
if (this.exoPlayer != null) {
exoPlayer.setRepeatMode(Player.REPEAT_MODE_ONE);
}
}
public long getDuration() {
if (this.exoPlayer != null) {
return this.exoPlayer.getDuration();
}
return 0L;
}
public long getPlaybackPosition() {
if (this.exoPlayer != null) {
return this.exoPlayer.getCurrentPosition();
}
return 0L;
}
/**
* After calling {@link #setPlaybackPosition}, the underlying {@link Player} resets the current position to 0.
* We manually store the offset of where we clipped to, and add that here.
*
* @return the current playback position, rounded to the nearest millisecond
*/
public long getTruePlaybackPosition() {
if (this.exoPlayer != null) {
return this.exoPlayer.getCurrentPosition() + Math.round(clippedStartUs / 1000.0);
}
return -1L;
}
public void setPlaybackPosition(long positionMs) {
if (this.exoPlayer != null) {
this.exoPlayer.seekTo(positionMs);
}
}
public void clip(long fromUs, long toUs, boolean playWhenReady) {
if (this.exoPlayer != null && mediaItem != null) {
MediaSource mediaItemSource = mediaSourceFactory.createMediaSource(mediaItem);
ClippingMediaSource clippedSource = new ClippingMediaSource(mediaItemSource, fromUs, toUs);
exoPlayer.setMediaSource(clippedSource);
exoPlayer.prepare();
exoPlayer.setPlayWhenReady(playWhenReady);
clipped = true;
clippedStartUs = fromUs;
}
}
public void removeClip(boolean playWhenReady) {
if (exoPlayer != null && mediaItem != null) {
if (clipped) {
exoPlayer.setMediaItem(mediaItem);
exoPlayer.prepare();
clipped = false;
clippedStartUs = 0;
}
exoPlayer.setPlayWhenReady(playWhenReady);
}
}
public void setWindow(@Nullable Window window) {
this.window = window;
}
public void setPlayerStateCallbacks(@Nullable PlayerStateCallback playerStateCallback) {
this.playerStateCallback = playerStateCallback;
}
public void setPlayerCallback(PlayerCallback playerCallback) {
this.playerCallback = playerCallback;
}
public void setPlayerPositionDiscontinuityCallback(@NonNull PlayerPositionDiscontinuityCallback playerPositionDiscontinuityCallback) {
this.playerPositionDiscontinuityCallback = playerPositionDiscontinuityCallback;
}
/**
* Resumes a paused video, or restarts if at end of video.
*/
public void play() {
if (exoPlayer != null) {
exoPlayer.setPlayWhenReady(true);
if (exoPlayer.getCurrentPosition() >= exoPlayer.getDuration()) {
exoPlayer.seekTo(0);
}
}
}
public void disableAudioFocus() {
requestAudioFocus = false;
}
private @NonNull MediaItem.ClippingConfiguration getClippingConfiguration(long startMs, long endMs) {
return startMs != endMs ? new MediaItem.ClippingConfiguration.Builder()
.setStartPositionMs(startMs)
.setEndPositionMs(endMs)
.build()
: MediaItem.ClippingConfiguration.UNSET;
}
private class ExoPlayerListener implements 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) {
switch (playbackState) {
case Player.STATE_IDLE:
case Player.STATE_BUFFERING:
case Player.STATE_ENDED:
if (window != null) {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
break;
case Player.STATE_READY:
if (window != null) {
if (playWhenReady) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
notifyPlayerReady();
break;
default:
break;
}
}
@Override
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition,
@NonNull Player.PositionInfo newPosition,
int reason)
{
if (playerPositionDiscontinuityCallback != null) {
playerPositionDiscontinuityCallback.onPositionDiscontinuity(VideoPlayer.this, reason);
}
}
private void notifyPlayerReady() {
if (playerStateCallback != null) playerStateCallback.onPlayerReady();
}
}
public interface PlayerStateCallback {
void onPlayerReady();
}
public interface PlayerPositionDiscontinuityCallback {
void onPositionDiscontinuity(@NonNull VideoPlayer player, int reason);
}
public interface PlayerCallback {
default void onReady() {}
void onPlaying();
void onStopped();
void onError(Exception e);
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.video.exo
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
fun ExoPlayer.configureForVideoPlayback() {
repeatMode = Player.REPEAT_MODE_OFF
volume = 1f
trackSelectionParameters = trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.build()
}
@@ -0,0 +1,211 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.video.exo
import androidx.annotation.MainThread
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import androidx.media3.exoplayer.ExoPlayer
import org.signal.core.util.AppForegroundObserver
import org.signal.core.util.logging.Log
import kotlin.collections.iterator
/**
* 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 {
companion object {
private val TAG = Log.tag(ExoPlayerPool::class.java)
}
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(tag: String): T? {
return get(allowReserved = false, tag = tag)
}
/**
* 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(tag: String): T {
return checkNotNull(get(allowReserved = true, tag = tag)) { "Required exoPlayer could not be acquired for $tag! :: ${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) {
exoPlayer.stop()
exoPlayer.clearMediaItems()
pool[exoPlayer] = poolState.copy(available = true, tag = null)
} else {
throw IllegalArgumentException("Tried to return unknown ExoPlayer to pool :: ${poolStats()}")
}
}
@MainThread
private fun get(allowReserved: Boolean, tag: String): T? {
val player = findAvailablePlayer(allowReserved)
val toReturn = if (player == null && pool.size < getMaximumAllowed(allowReserved)) {
val newPlayer = createPlayer()
val poolState = createPoolStateForNewEntry(allowReserved, tag)
pool[newPlayer] = poolState
newPlayer
} else if (player != null) {
val poolState = pool[player]!!.copy(available = false, tag = tag)
pool[player] = poolState
player
} else {
Log.d(TAG, "Failed to get an ExoPlayer instance for tag: $tag :: ${poolStats()}")
null
}
return toReturn?.apply {
configureForVideoPlayback()
}
}
private fun getMaximumAllowed(allowReserved: Boolean): Int {
return if (allowReserved) getMaxSimultaneousPlayback() else getMaxSimultaneousPlayback() - maximumReservedPlayers
}
private fun createPoolStateForNewEntry(allowReserved: Boolean, tag: String?): PoolState {
return if (allowReserved && pool.none { (_, v) -> v.reserved }) {
PoolState(available = false, reserved = true, tag = tag)
} else {
PoolState(available = false, reserved = false, tag = tag)
}
}
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() {
for ((player, state) in pool) {
if (!state.available && player.playWhenReady) {
Log.w(TAG, "Force-stopping orphaned playing player on background. Owner: ${state.tag}")
player.stop()
player.clearMediaItems()
}
}
val playersToRelease = pool.filter { (_, v) -> v.available }.keys
pool -= playersToRelease
playersToRelease.forEach { it.release() }
}
private fun poolStats(): String {
return getPoolStats().toString()
}
fun getPoolStats(): PoolStats {
val poolStats = PoolStats(
created = pool.size,
maxUnreserved = getMaxSimultaneousPlayback() - maximumReservedPlayers,
maxReserved = maximumReservedPlayers,
owners = emptyList()
)
return pool.values.fold(poolStats) { acc, state ->
Log.d(TAG, "$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,
owners = if (!state.available) acc.owners + OwnershipInfo(state.tag!!, state.reserved) else acc.owners
)
}
}
@UnstableApi
object DataSourceTransferListener : TransferListener {
private val TAG = Log.tag(DataSourceTransferListener::class)
override fun onTransferInitializing(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {
Log.d(TAG, "onTransferInitializing() for ${source.uri}")
}
override fun onTransferStart(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {
Log.d(TAG, "onTransferStart() for ${source.uri}")
}
override fun onBytesTransferred(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean, bytesTransferred: Int) {}
override fun onTransferEnd(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {
Log.d(TAG, "onTransferEnd() for ${source.uri}")
}
}
protected abstract fun getMaxSimultaneousPlayback(): Int
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,
val owners: List<OwnershipInfo>
)
data class OwnershipInfo(
val tag: String,
val isReserved: Boolean
)
private data class PoolState(
val available: Boolean,
val reserved: Boolean,
val tag: String?
) {
val unreservedAndAvailable = available && !reserved
val reservedAndAvailable = available && reserved
}
}
@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.video.interfaces
import android.content.Context
import android.net.Uri
import okio.IOException
interface MediaInputFactory {
@Throws(IOException::class)
fun createForUri(context: Context, uri: Uri): MediaInput
}
@@ -18,12 +18,11 @@ import org.thoughtcrime.securesms.video.videoconverter.utils.MediaCodecCompat;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@RequiresApi(api = 23)
final class VideoThumbnailsExtractor {
public final class VideoThumbnailsExtractor {
private static final String TAG = Log.tag(VideoThumbnailsExtractor.class);
interface Callback {
public interface Callback {
void durationKnown(long duration);
boolean publishProgress(int index, Bitmap thumbnail);
@@ -31,10 +30,10 @@ final class VideoThumbnailsExtractor {
void failed();
}
static void extractThumbnails(final @NonNull MediaInput input,
final int thumbnailCount,
final int thumbnailResolution,
final @NonNull Callback callback)
public static void extractThumbnails(final @NonNull MediaInput input,
final int thumbnailCount,
final int thumbnailResolution,
final @NonNull Callback callback)
{
MediaExtractor extractor = null;
MediaCodec decoder = null;