From 83cb48d1199fd7b91d2651a17498f662216feac2 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 17 Jun 2026 16:59:08 -0300 Subject: [PATCH] Move the VideoEditorFragment to the media-send feature module. --- .../audio/AudioWaveFormGenerator.java | 2 +- .../securesms/dependencies/AppDependencies.kt | 7 +- .../ApplicationDependencyProvider.java | 22 +- .../MediaSendDependenciesProvider.kt | 4 + .../logsubmit/LogSectionExoPlayerPool.kt | 2 +- .../media/DecryptableUriMediaInput.kt | 6 +- .../mediapreview/VideoControlsDelegate.kt | 2 +- .../VideoMediaPreviewFragment.java | 8 +- .../securesms/mediasend/VideoTrimTransform.kt | 2 +- .../securesms/mediasend/v2/MediaAnimations.kt | 2 +- .../mediasend/v2/MediaSelectionRepository.kt | 2 +- .../mediasend/v2/MediaSelectionState.kt | 2 +- .../mediasend/v2/MediaSelectionViewModel.kt | 2 +- .../v2/review/MediaReviewFragment.kt | 7 +- .../v2/videos/MediaReviewVideoPageFragment.kt | 60 +-- .../mediasend/v3/MediaSendV3Repository.kt | 8 +- .../revealable/ViewOnceMessageActivity.java | 11 +- .../stories/viewer/post/StoryPostFragment.kt | 2 +- .../stories/viewer/post/StoryVideoLoader.kt | 7 +- .../securesms/video/exo/ExoPlayer.kt | 8 - .../video/exo/SignalMediaSourceFactory.java | 1 + .../video/exo/SimpleExoPlayerPool.kt | 204 +--------- .../layout/media_preview_video_fragment.xml | 2 +- .../main/res/layout/stories_post_fragment.xml | 2 +- .../res/layout/v2_media_review_fragment.xml | 2 +- .../res/layout/view_once_message_activity.xml | 2 +- app/src/main/res/values-v19/colors.xml | 4 - app/src/main/res/values/attrs.xml | 4 - .../MockApplicationDependencyProvider.kt | 5 +- .../securesms/video/exo/ExoPlayerPoolTest.kt | 1 + .../org/signal/core/ui/view}/Animations.kt | 7 +- .../drawable/circle_transparent_black_60.xml | 7 +- feature/media-send/build.gradle.kts | 4 + .../java/org/signal/mediasend/EditorState.kt | 41 +- .../signal/mediasend/MediaSendDependencies.kt | 6 + .../signal/mediasend/MediaSendNavDisplay.kt | 3 - .../org/signal/mediasend/MediaSendScreen.kt | 2 - .../org/signal/mediasend/MediaSendState.kt | 5 + .../signal/mediasend/MediaSendViewModel.kt | 24 +- .../signal/mediasend/edit/EditorController.kt | 353 ------------------ .../signal/mediasend/edit/ImageController.kt | 352 +++++++++++++++++ .../signal/mediasend/edit/MediaEditScreen.kt | 143 +++++-- ...ndDialogs.kt => MediaEditScreenDialogs.kt} | 4 +- .../mediasend/edit/MediaEditScreenEvent.kt | 3 + .../mediasend/edit/{ => image}/HSVColorBar.kt | 2 +- .../mediasend/edit/{ => image}/ImageEditor.kt | 11 +- .../edit/{ => image}/ImageEditorState.kt | 2 +- .../edit/{ => image}/ImageEditorToolbar.kt | 28 +- .../edit/{ => image}/RotationDial.kt | 7 +- .../edit/video}/VideoEditorFragment.kt | 190 ++++------ .../video}/VideoEditorPlayButtonLayout.kt | 16 +- .../edit/video/VideoEditorToolbar.kt | 92 +++++ .../edit/video/VideoEditorViewModel.kt | 59 +++ .../VideoThumbnailsRangeSelectorView.java | 17 +- .../edit/video}/VideoThumbnailsView.java | 23 +- .../mediasend/edit/video}/VideoTrimData.kt | 9 +- .../res/layout/mediasend_video_fragment.xml | 9 +- .../src/main/res/layout/video_editor_hud.xml | 9 +- .../src/main/res/values/strings.xml | 2 + lib/video/build.gradle.kts | 2 + .../java/org/signal}/video/VideoPlayer.java | 43 +-- .../signal/video/exo/ExoPlayerExtensions.kt | 18 + .../org/signal/video/exo/ExoPlayerPool.kt | 211 +++++++++++ .../video/interfaces/MediaInputFactory.kt | 15 + .../VideoThumbnailsExtractor.java | 13 +- .../layout/media_preview_exoplayer_layout.xml | 5 + .../src/main/res/layout/video_player.xml | 5 + lib/video/src/main/res/values/attrs.xml | 10 + 68 files changed, 1235 insertions(+), 910 deletions(-) delete mode 100644 app/src/main/res/values-v19/colors.xml rename {app/src/main/java/org/thoughtcrime/securesms/util => core/ui/src/main/java/org/signal/core/ui/view}/Animations.kt (87%) rename {app => core/ui}/src/main/res/drawable/circle_transparent_black_60.xml (51%) delete mode 100644 feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt rename feature/media-send/src/main/java/org/signal/mediasend/edit/{MediaSendDialogs.kt => MediaEditScreenDialogs.kt} (91%) rename feature/media-send/src/main/java/org/signal/mediasend/edit/{ => image}/HSVColorBar.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/edit/{ => image}/ImageEditor.kt (95%) rename feature/media-send/src/main/java/org/signal/mediasend/edit/{ => image}/ImageEditorState.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/edit/{ => image}/ImageEditorToolbar.kt (92%) rename feature/media-send/src/main/java/org/signal/mediasend/edit/{ => image}/RotationDial.kt (97%) rename {app/src/main/java/org/thoughtcrime/securesms/mediasend => feature/media-send/src/main/java/org/signal/mediasend/edit/video}/VideoEditorFragment.kt (55%) rename {app/src/main/java/org/thoughtcrime/securesms/scribbles => feature/media-send/src/main/java/org/signal/mediasend/edit/video}/VideoEditorPlayButtonLayout.kt (82%) create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorToolbar.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorViewModel.kt rename {app/src/main/java/org/thoughtcrime/securesms/video/videoconverter => feature/media-send/src/main/java/org/signal/mediasend/edit/video}/VideoThumbnailsRangeSelectorView.java (96%) rename {app/src/main/java/org/thoughtcrime/securesms/video/videoconverter => feature/media-send/src/main/java/org/signal/mediasend/edit/video}/VideoThumbnailsView.java (91%) rename {app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos => feature/media-send/src/main/java/org/signal/mediasend/edit/video}/VideoTrimData.kt (87%) rename {app => feature/media-send}/src/main/res/layout/mediasend_video_fragment.xml (75%) rename {app => feature/media-send}/src/main/res/layout/video_editor_hud.xml (84%) rename {app/src/main/java/org/thoughtcrime/securesms => lib/video/src/main/java/org/signal}/video/VideoPlayer.java (90%) create mode 100644 lib/video/src/main/java/org/signal/video/exo/ExoPlayerExtensions.kt create mode 100644 lib/video/src/main/java/org/signal/video/exo/ExoPlayerPool.kt create mode 100644 lib/video/src/main/java/org/thoughtcrime/securesms/video/interfaces/MediaInputFactory.kt rename {app => lib/video}/src/main/res/layout/media_preview_exoplayer_layout.xml (84%) rename {app => lib/video}/src/main/res/layout/video_player.xml (91%) create mode 100644 lib/video/src/main/res/values/attrs.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveFormGenerator.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveFormGenerator.java index 59920f28ce..24ca3574f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveFormGenerator.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveFormGenerator.java @@ -33,7 +33,7 @@ public final class AudioWaveFormGenerator { */ @WorkerThread public static @NonNull AudioFileInfo generateWaveForm(@NonNull Context context, @NonNull Uri uri) throws IOException { - try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) { + try (MediaInput dataSource = DecryptableUriMediaInput.INSTANCE.createForUri(context, uri)) { long[] wave = new long[BAR_COUNT]; int[] waveSamples = new int[BAR_COUNT]; diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 32346ec704..9da386887a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies import android.annotation.SuppressLint import android.app.Application +import androidx.media3.exoplayer.ExoPlayer import io.reactivex.rxjava3.subjects.BehaviorSubject import okhttp3.OkHttpClient import org.signal.core.ui.CoreUiDependencies @@ -32,6 +33,7 @@ import org.signal.network.api.SvrBApi import org.signal.network.api.UsernameApi import org.signal.network.rest.SignalRestClient import org.signal.network.service.MessageService +import org.signal.video.exo.ExoPlayerPool import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.components.TypingStatusRepository import org.thoughtcrime.securesms.components.TypingStatusSender @@ -61,7 +63,6 @@ import org.thoughtcrime.securesms.shakereport.ShakeToReport import org.thoughtcrime.securesms.util.EarlyMessageCache import org.thoughtcrime.securesms.util.FrameRateTracker import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache -import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat import org.whispersystems.signalservice.api.SignalServiceAccountManager import org.whispersystems.signalservice.api.SignalServiceDataStore @@ -219,7 +220,7 @@ object AppDependencies { } @JvmStatic - val exoPlayerPool: SimpleExoPlayerPool by lazy { + val exoPlayerPool: ExoPlayerPool by lazy { provider.provideExoPlayerPool() } @@ -476,7 +477,7 @@ object AppDependencies { fun providePendingRetryReceiptCache(): PendingRetryReceiptCache fun provideProtocolStore(): SignalServiceDataStoreImpl fun provideGiphyMp4Cache(): GiphyMp4Cache - fun provideExoPlayerPool(): SimpleExoPlayerPool + fun provideExoPlayerPool(): ExoPlayerPool fun provideAndroidCallAudioManager(): AudioManagerCompat fun provideDonationsService(donationsApi: DonationsApi): DonationsService fun provideProfileService(profileOperations: ClientZkProfileOperations, authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): ProfileService 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 ea1c603ae9..4c96d8a4ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -7,12 +7,17 @@ import android.os.HandlerThread; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import androidx.media3.exoplayer.ExoPlayer; import org.jetbrains.annotations.NotNull; import org.signal.billing.BillingFactory; import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.PNI; +import org.signal.core.util.AppForegroundObserver; +import org.signal.core.util.ByteUnit; +import org.signal.core.util.SleepTimer; import org.signal.core.util.ThreadUtil; +import org.signal.core.util.UptimeSleepTimer; import org.signal.core.util.billing.BillingApi; import org.signal.core.util.concurrent.DeadlockDetector; import org.signal.core.util.concurrent.SignalExecutors; @@ -23,19 +28,20 @@ import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; import org.signal.network.api.ArchiveApi; -import org.signal.network.api.KeysApiV2; -import org.signal.network.api.MessageApiV2; -import org.signal.network.rest.SignalRestClient; +import org.signal.network.api.AttachmentApi; import org.signal.network.api.CallingApi; import org.signal.network.api.CdsApi; import org.signal.network.api.CertificateApi; +import org.signal.network.api.KeysApiV2; import org.signal.network.api.LinkDeviceApi; +import org.signal.network.api.MessageApiV2; import org.signal.network.api.PaymentsApi; import org.signal.network.api.ProvisioningApi; import org.signal.network.api.RateLimitChallengeApi; import org.signal.network.api.RemoteConfigApi; import org.signal.network.api.SvrBApi; import org.signal.network.api.UsernameApi; +import org.signal.network.rest.SignalRestClient; import org.signal.network.service.MessageService; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.components.TypingStatusRepository; @@ -94,16 +100,15 @@ import org.thoughtcrime.securesms.service.webrtc.SignalCallManager; import org.thoughtcrime.securesms.shakereport.ShakeToReport; import org.thoughtcrime.securesms.stories.Stories; import org.thoughtcrime.securesms.util.AlarmSleepTimer; -import org.signal.core.util.AppForegroundObserver; -import org.signal.core.util.ByteUnit; import org.thoughtcrime.securesms.util.EarlyMessageCache; import org.thoughtcrime.securesms.util.Environment; import org.thoughtcrime.securesms.util.FrameRateTracker; import org.thoughtcrime.securesms.util.PreKeyBatcher; import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache; import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool; +import org.signal.video.exo.ExoPlayerPool; +import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache; import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat; import org.whispersystems.signalservice.api.SignalServiceAccountDataStore; import org.whispersystems.signalservice.api.SignalServiceAccountManager; @@ -111,7 +116,6 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore; import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.account.AccountApi; -import org.signal.network.api.AttachmentApi; import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; import org.whispersystems.signalservice.api.donations.DonationsApi; import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; @@ -126,8 +130,6 @@ import org.whispersystems.signalservice.api.services.DonationsService; import org.whispersystems.signalservice.api.services.ProfileService; import org.whispersystems.signalservice.api.storage.StorageServiceApi; import org.whispersystems.signalservice.api.util.CredentialsProvider; -import org.signal.core.util.SleepTimer; -import org.signal.core.util.UptimeSleepTimer; import org.whispersystems.signalservice.api.websocket.SignalWebSocket; import org.whispersystems.signalservice.api.websocket.WebSocketFactory; import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException; @@ -490,7 +492,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { } @Override - public @NonNull SimpleExoPlayerPool provideExoPlayerPool() { + public @NonNull ExoPlayerPool provideExoPlayerPool() { return new SimpleExoPlayerPool(context); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaSendDependenciesProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaSendDependenciesProvider.kt index ac1b5005ee..abdb5d62fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaSendDependenciesProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaSendDependenciesProvider.kt @@ -5,9 +5,11 @@ package org.thoughtcrime.securesms.dependencies +import androidx.media3.exoplayer.ExoPlayer import org.signal.mediasend.MediaSendDependencies import org.signal.mediasend.MediaSendRepository import org.signal.mediasend.preupload.PreUploadRepository +import org.signal.video.exo.ExoPlayerPool import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3PreUploadRepository import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3Repository @@ -15,4 +17,6 @@ object MediaSendDependenciesProvider : MediaSendDependencies.Provider { override fun provideMediaSendRepository(): MediaSendRepository = MediaSendV3Repository override fun providePreUploadRepository(): PreUploadRepository = MediaSendV3PreUploadRepository + + override fun provideExoPlayerPool(): ExoPlayerPool = AppDependencies.exoPlayerPool } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionExoPlayerPool.kt b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionExoPlayerPool.kt index 1920f7b5b7..39773dea6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionExoPlayerPool.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionExoPlayerPool.kt @@ -1,8 +1,8 @@ package org.thoughtcrime.securesms.logsubmit import android.content.Context +import org.signal.video.exo.ExoPlayerPool import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.video.exo.ExoPlayerPool /** * Prints off the current exoplayer pool stats, including ownership info. diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.kt b/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.kt index 2e5f66503c..9191524e84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.kt @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.PartUriParser import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.video.interfaces.MediaInput +import org.thoughtcrime.securesms.video.interfaces.MediaInputFactory import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput import java.io.IOException @@ -15,10 +16,9 @@ import java.io.IOException * A media input source that is decrypted on the fly. */ @RequiresApi(api = 23) -object DecryptableUriMediaInput { - @JvmStatic +object DecryptableUriMediaInput : MediaInputFactory { @Throws(IOException::class) - fun createForUri(context: Context, uri: Uri): MediaInput { + override fun createForUri(context: Context, uri: Uri): MediaInput { if (BlobProvider.isAuthority(uri)) { return MediaDataSourceMediaInput(BlobProvider.getInstance().getMediaDataSource(context, uri)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt index 58c14fc174..7a2d324873 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.mediapreview import android.net.Uri -import org.thoughtcrime.securesms.video.VideoPlayer +import org.signal.video.VideoPlayer /** * Class to manage video playback in preview screen. diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java index 49297da7cf..1dd6e66b41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java @@ -20,10 +20,9 @@ import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.incrementalmac.InvalidMacException; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; -import org.thoughtcrime.securesms.mms.PartUriParser; -import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.video.VideoPlayer; +import org.signal.video.VideoPlayer; import java.util.concurrent.TimeUnit; @@ -71,7 +70,8 @@ public final class VideoMediaPreviewFragment extends MediaPreviewFragment { })); videoView.setWindow(requireActivity().getWindow()); - videoView.setVideoSource(new VideoSlide(getContext(), uri, size, false), autoPlay, TAG); + videoView.setExoPlayerPool(AppDependencies.getExoPlayerPool()); + videoView.setVideoSource(uri, autoPlay, TAG); videoView.setPlayerPositionDiscontinuityCallback((v, r) -> { if (events.getVideoControlsDelegate() != null) { events.getVideoControlsDelegate().onPlayerPositionDiscontinuity(r); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt index 23cf0d7102..ed8eb926c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt @@ -5,7 +5,7 @@ import androidx.annotation.WorkerThread import org.signal.core.models.media.Media import org.signal.core.models.media.TransformProperties import org.signal.mediasend.SentMediaQuality -import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData +import org.signal.mediasend.edit.video.VideoTrimData class VideoTrimTransform(private val data: VideoTrimData) : MediaTransform { @WorkerThread diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaAnimations.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaAnimations.kt index 65542a0ee7..ea010758d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaAnimations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaAnimations.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.mediasend.v2 import android.view.animation.Interpolator -import org.thoughtcrime.securesms.util.createDefaultCubicBezierInterpolator +import org.signal.core.ui.view.createDefaultCubicBezierInterpolator object MediaAnimations { /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index 44b726f06b..b300a2590c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -15,6 +15,7 @@ import org.signal.core.util.logging.Log import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.MediaConstraints import org.signal.mediasend.SentMediaQuality +import org.signal.mediasend.edit.video.VideoTrimData import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.database.SignalDatabase @@ -32,7 +33,6 @@ import org.thoughtcrime.securesms.mediasend.MediaTransform import org.thoughtcrime.securesms.mediasend.MediaUploadRepository import org.thoughtcrime.securesms.mediasend.SentMediaQualityTransform import org.thoughtcrime.securesms.mediasend.VideoTrimTransform -import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData import org.thoughtcrime.securesms.mms.GifSlide import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.OutgoingMessage diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt index cb4d35098d..8fbafdeb05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt @@ -4,9 +4,9 @@ import android.net.Uri import org.signal.core.models.media.Media import org.signal.mediasend.MediaConstraints import org.signal.mediasend.SentMediaQuality +import org.signal.mediasend.edit.video.VideoTrimData import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData import org.thoughtcrime.securesms.mms.PushMediaConstraints import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.stories.Stories diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index f43de41fac..9edfc12633 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -26,12 +26,12 @@ import org.signal.core.util.getParcelableCompat import org.signal.core.util.logging.Log import org.signal.mediasend.MediaConstraints import org.signal.mediasend.SentMediaQuality +import org.signal.mediasend.edit.video.VideoTrimData import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.conversation.MessageStyler import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult -import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData import org.thoughtcrime.securesms.mms.PushMediaConstraints import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index 9f12e3aebd..2d95fd0b96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -41,6 +41,8 @@ import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.logging.Log import org.signal.mediasend.MediaConstraints import org.signal.mediasend.SentMediaQuality +import org.signal.mediasend.edit.video.VideoThumbnailsRangeSelectorView +import org.signal.mediasend.edit.video.VideoTrimData import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType @@ -51,6 +53,7 @@ import org.thoughtcrime.securesms.conversation.ScheduleMessageTimePickerBottomSh import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardActivity import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.media.DecryptableUriMediaInput import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.mediasend.v2.HudCommand import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations @@ -58,7 +61,6 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel import org.thoughtcrime.securesms.mediasend.v2.stories.StoriesMultiselectForwardActivity -import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.util.MediaUtil @@ -68,7 +70,6 @@ import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout import org.thoughtcrime.securesms.util.visible import org.thoughtcrime.securesms.video.TranscodingQuality -import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView import java.io.IOException import java.util.Locale import java.util.concurrent.TimeUnit @@ -576,7 +577,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul return } val uri = mediaItem.uri - val updatedInputInTimeline = videoTimeLine.setInput(uri) + val updatedInputInTimeline = videoTimeLine.setInput(uri, DecryptableUriMediaInput) if (updatedInputInTimeline) { videoTimeLine.unregisterDragListener() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt index 9e35a73f30..c1aa972ced 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt @@ -5,9 +5,15 @@ import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import org.signal.core.util.getParcelableCompat +import org.signal.mediasend.edit.video.VideoEditorFragment +import org.signal.mediasend.edit.video.VideoEditorViewModel +import org.signal.mediasend.edit.video.VideoThumbnailsRangeSelectorView import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.mediasend.VideoEditorFragment import org.thoughtcrime.securesms.mediasend.v2.HudCommand import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel @@ -17,40 +23,52 @@ private const val VIDEO_EDITOR_TAG = "video.editor.fragment" * Page fragment which displays a single editable video (non-gif) to the user. Has an embedded MediaSendVideoFragment * and adds some extra support for saving and restoring state, as well as saving a video to disk. */ -class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), VideoEditorFragment.Controller { +class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container) { private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() }) + private val videoEditorViewModel: VideoEditorViewModel by viewModels(ownerProducer = { requireActivity() }) private lateinit var videoEditorFragment: VideoEditorFragment + private lateinit var videoTimeLine: VideoThumbnailsRangeSelectorView override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + videoTimeLine = requireActivity().findViewById(R.id.video_timeline) + videoEditorFragment = ensureVideoEditorFragment() - } - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - restoreVideoEditorState() - } + videoTimeLine.registerPlayerDragListener(object : VideoThumbnailsRangeSelectorView.PositionDragListener { + override fun onPositionDrag(position: Long) { + focusedUri()?.let { videoEditorViewModel.sendCommand(it, VideoEditorViewModel.Command.PositionDrag(position)) } + } - override fun onPlayerReady() { - sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition) - } + override fun onEndPositionDrag(position: Long) { + focusedUri()?.let { videoEditorViewModel.sendCommand(it, VideoEditorViewModel.Command.EndPositionDrag(position)) } + } + }) - override fun onPlayerError() { - sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition) - } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + videoEditorViewModel.events(requireUri()).collect { event -> + when (event) { + VideoEditorViewModel.Event.PlayerReady, VideoEditorViewModel.Event.PlayerError -> sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition) + is VideoEditorViewModel.Event.TouchEventsNeeded -> sharedViewModel.setTouchEnabled(!event.needed) + is VideoEditorViewModel.Event.ActualPositionChanged -> videoTimeLine.setActualPosition(event.positionUs) + } + } + } + } - override fun onTouchEventsNeeded(needed: Boolean) { - sharedViewModel.setTouchEnabled(!needed) - } - private fun restoreVideoEditorState() { - val data = sharedViewModel.getEditorState(requireUri()) as? VideoTrimData - - if (data != null) { - videoEditorFragment.restoreState(data) + sharedViewModel.state.observe(viewLifecycleOwner) { incomingState -> + videoEditorFragment.onStateUpdate( + incomingState.focusedMedia?.uri, + incomingState.isTouchEnabled, + incomingState::getOrCreateVideoTrimData + ) } } + private fun focusedUri(): Uri? = sharedViewModel.state.value?.focusedMedia?.uri + private fun ensureVideoEditorFragment(): VideoEditorFragment { val fragmentInManager: VideoEditorFragment? = childFragmentManager.findFragmentByTag(VIDEO_EDITOR_TAG) as? VideoEditorFragment diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index 3b4a66740e..dd81f257f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -35,7 +35,6 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.mediasend.MediaRepository import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionRepository import org.thoughtcrime.securesms.mediasend.v2.MediaValidator -import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.PushMediaConstraints import org.thoughtcrime.securesms.providers.BlobProvider @@ -202,12 +201,7 @@ object MediaSendV3Repository : MediaSendRepository { return editorStateMap.mapNotNull { (uri, state) -> val legacyState: Any = when (state) { is EditorState.Image -> ImageEditorFragment.Data().apply { writeModel(state.model) } - is EditorState.VideoTrim -> VideoTrimData( - isDurationEdited = state.isDurationEdited, - totalInputDurationUs = state.totalInputDurationUs, - startTimeUs = state.startTimeUs, - endTimeUs = state.endTimeUs - ) + is EditorState.VideoTrim -> state.videoTrimData } uri to legacyState }.toMap() diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageActivity.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageActivity.java index ae3fca006c..26821bde39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageActivity.java @@ -19,14 +19,14 @@ import androidx.lifecycle.ViewModelProvider; import com.bumptech.glide.Glide; import org.signal.core.util.logging.Log; +import org.signal.glide.decryptableuri.DecryptableUri; import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; -import org.signal.glide.decryptableuri.DecryptableUri; +import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.video.VideoPlayer; +import org.signal.video.VideoPlayer; import java.util.concurrent.TimeUnit; @@ -127,11 +127,10 @@ public class ViewOnceMessageActivity extends PassphraseRequiredActivity implemen image.setVisibility(View.GONE); duration.setVisibility(View.VISIBLE); - VideoSlide videoSlide = new VideoSlide(this, uri, 0, false); - video.setWindow(getWindow()); video.setPlayerStateCallbacks(this); - video.setVideoSource(videoSlide, true, TAG); + video.setExoPlayerPool(AppDependencies.getExoPlayerPool()); + video.setVideoSource(uri, true, TAG); video.hideControls(); video.loopForever(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostFragment.kt index 603d3039e5..7f5951758c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostFragment.kt @@ -7,6 +7,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.logging.Log +import org.signal.video.VideoPlayer.PlayerCallback import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner @@ -16,7 +17,6 @@ import org.thoughtcrime.securesms.stories.viewer.page.StoryDisplay import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageViewModel import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.visible -import org.thoughtcrime.securesms.video.VideoPlayer.PlayerCallback /** * Renders a given StoryPost object as a viewable story. diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryVideoLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryVideoLoader.kt index 5c082c5c60..e554411fb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryVideoLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryVideoLoader.kt @@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.stories.viewer.post import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.mms.VideoSlide -import org.thoughtcrime.securesms.video.VideoPlayer +import org.signal.video.VideoPlayer +import org.thoughtcrime.securesms.dependencies.AppDependencies /** * Render logic for story video posts @@ -23,7 +23,8 @@ class StoryVideoLoader( fun load() { fragment.viewLifecycleOwner.lifecycle.addObserver(this) - videoPlayer.setVideoSource(VideoSlide(fragment.requireContext(), videoPost.videoUri, videoPost.size, false), false, TAG, videoPost.clipStart.inWholeMilliseconds, videoPost.clipEnd.inWholeMilliseconds) + videoPlayer.setExoPlayerPool(AppDependencies.exoPlayerPool) + videoPlayer.setVideoSource(videoPost.videoUri, false, TAG, videoPost.clipStart.inWholeMilliseconds, videoPost.clipEnd.inWholeMilliseconds) videoPlayer.hideControls() videoPlayer.setKeepContentOnPlayerReset(false) blurLoader.load() 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 index 83baaaf1af..951159648c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/ExoPlayer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/ExoPlayer.kt @@ -11,11 +11,3 @@ fun ExoPlayer.configureForGifPlayback() { .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, true) .build() } - -fun ExoPlayer.configureForVideoPlayback() { - repeatMode = Player.REPEAT_MODE_OFF - volume = 1f - trackSelectionParameters = trackSelectionParameters.buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) - .build() -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalMediaSourceFactory.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalMediaSourceFactory.java index 9e2f0f8bf4..675622ede8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalMediaSourceFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalMediaSourceFactory.java @@ -16,6 +16,7 @@ import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.extractor.DefaultExtractorsFactory; import androidx.media3.extractor.ExtractorsFactory; +import org.signal.video.exo.ExoPlayerPool; /** * This class is responsible for creating a MediaSource object for a given Uri, using {@link SignalDataSource.Factory}. */ 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 index d8852b6211..49d9162aa6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.video.exo import android.content.Context @@ -6,14 +11,12 @@ import androidx.annotation.OptIn import androidx.media3.common.MimeTypes 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 androidx.media3.exoplayer.mediacodec.MediaCodecUtil import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import org.signal.core.util.AppForegroundObserver -import org.signal.core.util.logging.Log +import org.signal.video.exo.ExoPlayerPool import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.net.ContentProxySelector import org.thoughtcrime.securesms.util.DeviceProperties @@ -76,198 +79,3 @@ class SimpleExoPlayerPool(context: Context) : ExoPlayerPool(MAXIMUM_R private val SEEK_INTERVAL = 15.seconds } } - -/** - * 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 { - - companion object { - private val TAG = Log.tag(ExoPlayerPool::class.java) - } - - 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(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 - ) - - 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 - } -} diff --git a/app/src/main/res/layout/media_preview_video_fragment.xml b/app/src/main/res/layout/media_preview_video_fragment.xml index 74b33233e6..527af18aa1 100644 --- a/app/src/main/res/layout/media_preview_video_fragment.xml +++ b/app/src/main/res/layout/media_preview_video_fragment.xml @@ -6,7 +6,7 @@ android:orientation="vertical" tools:viewBindingIgnore="true"> - - - - - - #22000000 - \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 34e7fca11b..7e781fdd33 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -273,10 +273,6 @@ - - - - diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 0a2f9fc198..8fa75ec8ad 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.dependencies +import androidx.media3.exoplayer.ExoPlayer import io.mockk.mockk import org.signal.core.util.billing.BillingApi import org.signal.core.util.concurrent.DeadlockDetector @@ -19,6 +20,7 @@ import org.signal.network.api.RemoteConfigApi import org.signal.network.api.SvrBApi import org.signal.network.api.UsernameApi import org.signal.network.rest.SignalRestClient +import org.signal.video.exo.ExoPlayerPool import org.thoughtcrime.securesms.components.TypingStatusRepository import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl @@ -45,7 +47,6 @@ import org.thoughtcrime.securesms.shakereport.ShakeToReport import org.thoughtcrime.securesms.util.EarlyMessageCache import org.thoughtcrime.securesms.util.FrameRateTracker import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache -import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat import org.whispersystems.signalservice.api.SignalServiceAccountManager import org.whispersystems.signalservice.api.SignalServiceDataStore @@ -204,7 +205,7 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk(relaxed = true) } - override fun provideExoPlayerPool(): SimpleExoPlayerPool { + override fun provideExoPlayerPool(): ExoPlayerPool { return mockk(relaxed = true) } 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 index d3b94c9f20..cd62a195a9 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/video/exo/ExoPlayerPoolTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/video/exo/ExoPlayerPoolTest.kt @@ -12,6 +12,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.signal.video.exo.ExoPlayerPool @RunWith(RobolectricTestRunner::class) @Config(application = Application::class) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Animations.kt b/core/ui/src/main/java/org/signal/core/ui/view/Animations.kt similarity index 87% rename from app/src/main/java/org/thoughtcrime/securesms/util/Animations.kt rename to core/ui/src/main/java/org/signal/core/ui/view/Animations.kt index d49f1bf771..ad6ddbdb92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Animations.kt +++ b/core/ui/src/main/java/org/signal/core/ui/view/Animations.kt @@ -1,4 +1,9 @@ -package org.thoughtcrime.securesms.util +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.view import android.view.animation.Animation import android.view.animation.Interpolator diff --git a/app/src/main/res/drawable/circle_transparent_black_60.xml b/core/ui/src/main/res/drawable/circle_transparent_black_60.xml similarity index 51% rename from app/src/main/res/drawable/circle_transparent_black_60.xml rename to core/ui/src/main/res/drawable/circle_transparent_black_60.xml index 3ea46f8b60..c09fee172a 100644 --- a/app/src/main/res/drawable/circle_transparent_black_60.xml +++ b/core/ui/src/main/res/drawable/circle_transparent_black_60.xml @@ -1,5 +1,10 @@ + + - + diff --git a/feature/media-send/build.gradle.kts b/feature/media-send/build.gradle.kts index 03f7dd1264..edc2704dc4 100644 --- a/feature/media-send/build.gradle.kts +++ b/feature/media-send/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { // Compose dependencies implementation(libs.androidx.activity.compose) + implementation(libs.androidx.fragment.compose) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling.core) @@ -63,6 +64,9 @@ dependencies { // Permissions implementation(libs.accompanist.permissions) + // Media + implementation(libs.androidx.media3.exoplayer) + // Testing testImplementation(testLibs.junit.junit) testImplementation(testLibs.mockk) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt index 8118e30fc5..f447e059bb 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt @@ -11,6 +11,7 @@ import androidx.core.os.bundleOf import kotlinx.parcelize.Parcelize import org.signal.core.util.getParcelableCompat import org.signal.imageeditor.core.model.EditorModel +import org.signal.mediasend.edit.video.VideoTrimData /** * Sealed interface for per-media editor state. All subtypes are [Parcelable] so the @@ -23,13 +24,10 @@ sealed interface EditorState : Parcelable { */ @Parcelize data class VideoTrim( - val isDurationEdited: Boolean = false, - val totalInputDurationUs: Long = 0, - val startTimeUs: Long = 0, - val endTimeUs: Long = 0 + val videoTrimData: VideoTrimData ) : EditorState { - val clipDurationUs: Long get() = endTimeUs - startTimeUs + val clipDurationUs: Long get() = videoTrimData.endTimeUs - videoTrimData.startTimeUs /** * Clamps this trim data to the maximum allowed clip duration. @@ -43,25 +41,21 @@ sealed interface EditorState : Parcelable { return this } - return copy( - isDurationEdited = true, - startTimeUs = if (!preserveStartTime) endTimeUs - maxDurationUs else startTimeUs, - endTimeUs = if (preserveStartTime) startTimeUs + maxDurationUs else endTimeUs + return VideoTrim( + videoTrimData = videoTrimData.copy( + isDurationEdited = true, + startTimeUs = if (!preserveStartTime) videoTrimData.endTimeUs - maxDurationUs else videoTrimData.startTimeUs, + endTimeUs = if (preserveStartTime) videoTrimData.startTimeUs + maxDurationUs else videoTrimData.endTimeUs + ) ) } companion object { - private const val KEY_IS_DURATION_EDITED = "isDurationEdited" - private const val KEY_TOTAL_INPUT_DURATION_US = "totalInputDurationUs" - private const val KEY_START_TIME_US = "startTimeUs" - private const val KEY_END_TIME_US = "endTimeUs" + private const val KEY_MODEL = "model" fun fromBundle(bundle: Bundle): VideoTrim { return VideoTrim( - isDurationEdited = bundle.getBoolean(KEY_IS_DURATION_EDITED, false), - totalInputDurationUs = bundle.getLong(KEY_TOTAL_INPUT_DURATION_US, 0), - startTimeUs = bundle.getLong(KEY_START_TIME_US, 0), - endTimeUs = bundle.getLong(KEY_END_TIME_US, 0) + videoTrimData = bundle.getParcelableCompat(KEY_MODEL, VideoTrimData::class.java)!! ) } @@ -69,29 +63,28 @@ sealed interface EditorState : Parcelable { * Creates initial trim data for a video, clamping to max duration if needed. */ fun forVideo(durationUs: Long, maxDurationUs: Long): VideoTrim { - return if (durationUs <= maxDurationUs) { - VideoTrim( + val videoTrimData = if (durationUs <= maxDurationUs) { + VideoTrimData( isDurationEdited = false, totalInputDurationUs = durationUs, startTimeUs = 0, endTimeUs = durationUs ) } else { - VideoTrim( + VideoTrimData( isDurationEdited = true, totalInputDurationUs = durationUs, startTimeUs = 0, endTimeUs = maxDurationUs ) } + + return VideoTrim(videoTrimData = videoTrimData) } } fun toBundle(): Bundle = Bundle().apply { - putBoolean(KEY_IS_DURATION_EDITED, isDurationEdited) - putLong(KEY_TOTAL_INPUT_DURATION_US, totalInputDurationUs) - putLong(KEY_START_TIME_US, startTimeUs) - putLong(KEY_END_TIME_US, endTimeUs) + putParcelable(KEY_MODEL, videoTrimData) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt index 1586c5ae48..421b90218d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt @@ -6,7 +6,9 @@ package org.signal.mediasend import android.app.Application +import androidx.media3.exoplayer.ExoPlayer import org.signal.mediasend.preupload.PreUploadRepository +import org.signal.video.exo.ExoPlayerPool /** * MediaSend Feature Module dependencies @@ -34,8 +36,12 @@ object MediaSendDependencies { val mediaSendRepository: MediaSendRepository get() = _provider.provideMediaSendRepository() + val exoPlayerPool: ExoPlayerPool + get() = _provider.provideExoPlayerPool() + interface Provider { fun provideMediaSendRepository(): MediaSendRepository fun providePreUploadRepository(): PreUploadRepository + fun provideExoPlayerPool(): ExoPlayerPool } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt index 66704363d4..1e10129091 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt @@ -39,7 +39,6 @@ fun MediaSendNavDisplay( modifier: Modifier = Modifier, cameraSlot: @Composable () -> Unit = {}, textStoryEditorSlot: @Composable () -> Unit = {}, - videoEditorSlot: @Composable () -> Unit = {}, sendSlot: @Composable (MediaSendState) -> Unit = {} ) { NavDisplay( @@ -91,7 +90,6 @@ fun MediaSendNavDisplay( val state by stateFlow.collectAsStateWithLifecycle() MediaEditScreen( state = state, - videoEditorSlot = videoEditorSlot, onEvent = eventHandler::onMediaEditScreenEvent ) } @@ -117,7 +115,6 @@ private fun MediaSendNavDisplayPreview() { eventHandler = MediaSendEventHandler.Empty, cameraSlot = { BoxWithText("Camera Slot") }, textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") }, - videoEditorSlot = { BoxWithText("Video Editor Slot") }, sendSlot = { _ -> BoxWithText("Send Slot") } ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt index b40a00768a..c3400292ec 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt @@ -22,7 +22,6 @@ fun MediaSendScreen( modifier: Modifier = Modifier, cameraSlot: @Composable () -> Unit = {}, textStoryEditorSlot: @Composable () -> Unit = {}, - videoEditorSlot: @Composable () -> Unit = {}, sendSlot: @Composable (MediaSendState) -> Unit = {}, onExternalHudCommand: (HudCommand) -> Unit = {} ) { @@ -44,7 +43,6 @@ fun MediaSendScreen( modifier = modifier, cameraSlot = cameraSlot, textStoryEditorSlot = textStoryEditorSlot, - videoEditorSlot = videoEditorSlot, sendSlot = sendSlot ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt index fa7c4eef9a..f986163e39 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt @@ -13,6 +13,7 @@ import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith import org.signal.core.models.media.Media import org.signal.core.models.media.MediaFolder +import org.signal.mediasend.edit.video.VideoTrimData /** * The collective state of the media send flow. @@ -121,6 +122,10 @@ data class MediaSendState( val selectedMediaFolderItems: @WriteWith List = emptyList() ) : Parcelable { + fun getOrCreateVideoTrimData(uri: Uri): VideoTrimData { + return (editorStateMap[uri] as? EditorState.VideoTrim)?.videoTrimData ?: VideoTrimData() + } + /** * No-op parcelers for fields that are re-loaded on init and should not * contribute to the saved-state bundle size. diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index 9d29644d17..c89449e873 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -43,6 +43,7 @@ import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.model.EditorModel import org.signal.imageeditor.core.renderers.UriGlideRenderer import org.signal.mediasend.edit.MediaEditScreenEvent +import org.signal.mediasend.edit.video.VideoTrimData import org.signal.mediasend.preupload.PreUploadController import org.signal.mediasend.select.MediaSelectScreenEvent import java.util.Collections @@ -182,6 +183,13 @@ class MediaSendViewModel( when (mediaEditScreenEvent) { is MediaEditScreenEvent.FocusedMediaChanged -> setFocusedMedia(mediaEditScreenEvent.media) MediaEditScreenEvent.NavigateToSend -> backStack.goToSend() + is MediaEditScreenEvent.VideoTrimChanged -> onEditVideoDuration( + totalDurationUs = mediaEditScreenEvent.videoTrimData.totalInputDurationUs, + startTimeUs = mediaEditScreenEvent.videoTrimData.startTimeUs, + endTimeUs = mediaEditScreenEvent.videoTrimData.endTimeUs, + touchEnabled = mediaEditScreenEvent.editingComplete + ) + is MediaEditScreenEvent.VideoSeek -> error("VideoSeek is routed to the video player bus by MediaEditScreen and must not reach the view-model.") is MediaEditScreenEvent.AddMessageClick -> { val snapshot: MediaSendState = state.value @@ -439,9 +447,9 @@ class MediaSendViewModel( val existingData = snapshot.editorStateMap[mediaItem.uri] as? EditorState.VideoTrim if (existingData != null) { onEditVideoDuration( - totalDurationUs = existingData.totalInputDurationUs, - startTimeUs = existingData.startTimeUs, - endTimeUs = existingData.endTimeUs, + totalDurationUs = existingData.videoTrimData.totalInputDurationUs, + startTimeUs = existingData.videoTrimData.startTimeUs, + endTimeUs = existingData.videoTrimData.endTimeUs, touchEnabled = true, uri = mediaItem.uri ) @@ -483,22 +491,22 @@ class MediaSendViewModel( val snapshot = state.value val existingData = snapshot.editorStateMap[uri] as? EditorState.VideoTrim - ?: EditorState.VideoTrim(totalInputDurationUs = totalDurationUs) + ?: EditorState.VideoTrim(VideoTrimData(totalInputDurationUs = totalDurationUs)) val clampedStartTime = maxOf(startTimeUs, 0) - val unedited = !existingData.isDurationEdited + val unedited = !existingData.videoTrimData.isDurationEdited val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs val isEntireDuration = startTimeUs == 0L && endTimeUs == totalDurationUs - val endMoved = !isEntireDuration && existingData.endTimeUs != endTimeUs + val endMoved = !isEntireDuration && existingData.videoTrimData.endTimeUs != endTimeUs val maxVideoDurationUs = getMaxVideoDurationUs() val preserveStartTime = unedited || !endMoved - val newData = EditorState.VideoTrim( + val newData = VideoTrimData( isDurationEdited = durationEdited, totalInputDurationUs = totalDurationUs, startTimeUs = clampedStartTime, endTimeUs = endTimeUs - ).clampToMaxDuration(maxVideoDurationUs, preserveStartTime) + ).let { EditorState.VideoTrim(it) }.clampToMaxDuration(maxVideoDurationUs, preserveStartTime) // Cancel upload on first edit if (unedited && durationEdited) { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt deleted file mode 100644 index 20f236b9db..0000000000 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt +++ /dev/null @@ -1,353 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.mediasend.edit - -import android.graphics.Paint -import android.net.Uri -import androidx.compose.runtime.Stable -import androidx.compose.runtime.annotation.RememberInComposition -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateMap -import org.signal.imageeditor.core.SelectableRenderer -import org.signal.imageeditor.core.model.EditorElement -import org.signal.imageeditor.core.model.EditorModel -import org.signal.imageeditor.core.renderers.MultiLineTextRenderer - -@Stable -sealed interface EditorController { - - @Stable - class Container @RememberInComposition constructor() { - private val controllers = SnapshotStateMap() - - fun getOrCreateImageController(uri: Uri, editorModel: EditorModel): Image { - return controllers.getOrPut(uri) { Image(editorModel) } as Image - } - } - - @Stable - class Image @RememberInComposition constructor(val editorModel: EditorModel) : EditorController { - - override val isUserInEdit: Boolean by derivedStateOf { mode != Mode.NONE } - - val imageEditorState = ImageEditorState(editorModel).also { - it.onGestureCompleted = { drawSessionDirty = true } - } - - var mode: Mode by mutableStateOf(Mode.NONE) - - var isCropAspectRatioLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked) - private set - - val dialRotation: Float - get() = editorModel.mainImage?.let { Math.toDegrees(it.localRotationAngle.toDouble()).toFloat() } ?: 0f - - private var initialDialScale: Float = editorModel.mainImage?.localScaleX ?: 1f - private var initialDialImageDegrees: Float = 0f - private var minDialScaleDown: Float = 1f - private var drawSessionSnapshot: ByteArray? = null - private var drawSessionDirty: Boolean by mutableStateOf(false) - - var textEditingElement: EditorElement? by mutableStateOf(null) - private set - - var selectedElement: EditorElement? by mutableStateOf(null) - private set - - val textColorBarState = HSVColorBarState() - - var showDiscardDialog: Boolean by mutableStateOf(false) - private set - - private val isInDrawSession: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR } - - val hasUnsavedChanges: Boolean by derivedStateOf { - when { - mode == Mode.CROP -> imageEditorState.undoAvailable - mode == Mode.TEXT -> (textEditingElement?.renderer as? MultiLineTextRenderer)?.text?.isNotEmpty() == true - isInDrawSession -> drawSessionDirty - else -> false - } - } - - val shouldDisplayColorBar: Boolean by derivedStateOf { - textEditingElement != null || mode == Mode.MOVE_TEXT - } - - val isUserDrawing: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT } - val isUserBlurring: Boolean by derivedStateOf { mode == Mode.BLUR } - val isUserEnteringText: Boolean by derivedStateOf { mode == Mode.TEXT } - val isUserInsertingSticker: Boolean by derivedStateOf { mode == Mode.INSERT_STICKER } - - fun requestCancelEdit() { - if (hasUnsavedChanges) { - showDiscardDialog = true - } else { - cancelEdit() - } - } - - fun dismissDiscardDialog() { - showDiscardDialog = false - } - - fun confirmDiscardEdit() { - showDiscardDialog = false - cancelEdit() - } - - fun beginDrawEdit() { - enterDrawMode() - } - - fun beginCropAndRotateEdit() { - enterCropMode() - } - - fun cancelEdit() { - when { - mode == Mode.TEXT -> { - finishTextEditing() - } - mode == Mode.CROP -> { - editorModel.clearUndoStack() - editorModel.doneCrop() - exitEditMode() - } - isInDrawSession -> { - drawSessionSnapshot?.let { editorModel.restoreFromSnapshot(it) } - exitEditMode() - } - else -> exitEditMode() - } - } - - fun commitEdit() { - when (mode) { - Mode.TEXT -> finishTextEditing() - Mode.CROP -> { - editorModel.doneCrop() - exitEditMode() - } - else -> exitEditMode() - } - } - - private fun exitEditMode() { - drawSessionSnapshot = null - drawSessionDirty = false - selectedElement = null - mode = Mode.NONE - imageEditorState.isDrawing = false - imageEditorState.isBlur = false - } - - fun enterDrawMode() { - snapshotIfNewDrawSession() - mode = Mode.DRAW - syncDrawingState() - } - - fun enterHighlightMode() { - snapshotIfNewDrawSession() - mode = Mode.HIGHLIGHT - syncDrawingState() - } - - fun enterBlurMode() { - snapshotIfNewDrawSession() - mode = Mode.BLUR - syncDrawingState() - } - - private fun snapshotIfNewDrawSession() { - if (!isInDrawSession) { - drawSessionSnapshot = editorModel.createSnapshot() - drawSessionDirty = false - } - } - - fun setDrawColor(color: Int) { - imageEditorState.drawColor = color - } - - fun setDrawThickness(thickness: Float) { - imageEditorState.drawThickness = thickness - } - - private fun syncDrawingState() { - imageEditorState.isDrawing = true - imageEditorState.isBlur = mode == Mode.BLUR - imageEditorState.drawCap = if (mode == Mode.HIGHLIGHT) Paint.Cap.SQUARE else Paint.Cap.ROUND - } - - fun enterCropMode() { - editorModel.startCrop() - initialDialScale = editorModel.mainImage?.localScaleX ?: 1f - mode = Mode.CROP - } - - fun enterTextMode() { - snapshotIfNewDrawSession() - val renderer = MultiLineTextRenderer("", textColorBarState.color, MultiLineTextRenderer.Mode.REGULAR) - val element = EditorElement(renderer, EditorModel.Z_TEXT) - editorModel.addElementCentered(element, 1f) - beginTextEditing(element) - } - - private fun beginTextEditing(element: EditorElement) { - mode = Mode.TEXT - textEditingElement = element - imageEditorState.textEditingElement = element - editorModel.addFade() - editorModel.setSelectionVisible(false) - (element.renderer as? MultiLineTextRenderer)?.setFocused(true) - } - - fun finishTextEditing() { - val element = textEditingElement ?: return - val renderer = element.renderer as? MultiLineTextRenderer - val hasText = renderer?.text?.isNotEmpty() == true - val snapshot = drawSessionSnapshot - - renderer?.setFocused(false) - editorModel.zoomOut() - editorModel.removeFade() - editorModel.setSelectionVisible(true) - - if (!hasText && snapshot != null) { - editorModel.restoreFromSnapshot(snapshot) - } - - editorModel.setSelected(null) - textEditingElement = null - imageEditorState.textEditingElement = null - exitEditMode() - } - - fun onTextChanged(text: String) { - val element = textEditingElement ?: return - val renderer = element.renderer as? MultiLineTextRenderer ?: return - renderer.setText(text) - imageEditorState.invalidate() - } - - fun onTextSelectionChanged(selStart: Int, selEnd: Int) { - val element = textEditingElement ?: return - val renderer = element.renderer as? MultiLineTextRenderer ?: return - renderer.setSelection(selStart, selEnd) - editorModel.zoomToTextElement(element, renderer) - imageEditorState.invalidate() - } - - fun setTextColor(color: Int) { - val element = textEditingElement ?: selectedElement - val renderer = element?.renderer as? MultiLineTextRenderer ?: return - renderer.color = color - imageEditorState.invalidate() - } - - fun onEntityTapped(element: EditorElement?) { - if (element != null && element.renderer is SelectableRenderer) { - (element.renderer as SelectableRenderer).onSelected(true) - editorModel.setSelected(element) - selectedElement = element - mode = when (element.renderer) { - is MultiLineTextRenderer -> Mode.MOVE_TEXT - else -> Mode.MOVE_STICKER - } - } else { - clearSelection() - } - } - - private fun clearSelection() { - if (selectedElement != null) { - (selectedElement?.renderer as? SelectableRenderer)?.onSelected(false) - editorModel.setSelected(null) - selectedElement = null - mode = Mode.NONE - imageEditorState.invalidate() - } - } - - fun enterStickerMode() { - mode = Mode.INSERT_STICKER - } - - fun lockCrop() { - editorModel.setCropAspectLock(true) - isCropAspectRatioLocked = true - } - - fun unlockCrop() { - editorModel.setCropAspectLock(false) - isCropAspectRatioLocked = false - } - - fun flip() { - editorModel.flipHorizontal() - } - - fun rotate() { - editorModel.rotate90anticlockwise() - } - - fun onDialGestureStart() { - val mainImage = editorModel.mainImage ?: return - initialDialScale = mainImage.localScaleX - minDialScaleDown = 1f - editorModel.pushUndoPoint() - editorModel.updateUndoRedoAvailabilityState() - initialDialImageDegrees = Math.toDegrees(mainImage.localRotationAngle.toDouble()).toFloat() - } - - fun onDialRotationChanged(degrees: Float) { - editorModel.setMainImageEditorMatrixRotation(degrees - initialDialImageDegrees, minDialScaleDown) - } - - fun onDialGestureEnd() { - val mainImage = editorModel.mainImage ?: return - mainImage.commitEditorMatrix() - editorModel.postEdit(true) - initialDialScale = mainImage.localScaleX - } - - fun toggleImageQuality() { - // TODO - } - - fun saveToDisk() { - // TODO - } - - fun addMedia() { - // TODO - } - - enum class Mode { - NONE, - CROP, - TEXT, - DRAW, - HIGHLIGHT, - BLUR, - MOVE_STICKER, - MOVE_TEXT, - DELETE, - INSERT_STICKER - } - } - - object VideoTrim : EditorController { - override val isUserInEdit: Boolean = false - } - - val isUserInEdit: Boolean -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt new file mode 100644 index 0000000000..64b7a86824 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt @@ -0,0 +1,352 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit + +import android.graphics.Paint +import android.net.Uri +import androidx.compose.runtime.Stable +import androidx.compose.runtime.annotation.RememberInComposition +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap +import org.signal.imageeditor.core.SelectableRenderer +import org.signal.imageeditor.core.model.EditorElement +import org.signal.imageeditor.core.model.EditorModel +import org.signal.imageeditor.core.renderers.MultiLineTextRenderer +import org.signal.mediasend.edit.image.HSVColorBarState +import org.signal.mediasend.edit.image.ImageEditorState + +/** + * Holds the editor state for a single image (modes, undo, selection, etc.). + * + * Videos have no comparable per-item editor state — no undo stack, no modes — so they are rendered directly based + * on the focused media type rather than through a controller. Trim data lives in the view-model state, and transient + * trim/seek interaction flows through the screen's event channel. + */ +@Stable +class ImageController @RememberInComposition constructor(val editorModel: EditorModel) { + + val isUserInEdit: Boolean by derivedStateOf { mode != Mode.NONE } + + val imageEditorState = ImageEditorState(editorModel).also { + it.onGestureCompleted = { drawSessionDirty = true } + } + + var mode: Mode by mutableStateOf(Mode.NONE) + + var isCropAspectRatioLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked) + private set + + val dialRotation: Float + get() = editorModel.mainImage?.let { Math.toDegrees(it.localRotationAngle.toDouble()).toFloat() } ?: 0f + + private var initialDialScale: Float = editorModel.mainImage?.localScaleX ?: 1f + private var initialDialImageDegrees: Float = 0f + private var minDialScaleDown: Float = 1f + private var drawSessionSnapshot: ByteArray? = null + private var drawSessionDirty: Boolean by mutableStateOf(false) + + var textEditingElement: EditorElement? by mutableStateOf(null) + private set + + var selectedElement: EditorElement? by mutableStateOf(null) + private set + + val textColorBarState = HSVColorBarState() + + var showDiscardDialog: Boolean by mutableStateOf(false) + private set + + private val isInDrawSession: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR } + + val hasUnsavedChanges: Boolean by derivedStateOf { + when { + mode == Mode.CROP -> imageEditorState.undoAvailable + mode == Mode.TEXT -> (textEditingElement?.renderer as? MultiLineTextRenderer)?.text?.isNotEmpty() == true + isInDrawSession -> drawSessionDirty + else -> false + } + } + + val shouldDisplayColorBar: Boolean by derivedStateOf { + textEditingElement != null || mode == Mode.MOVE_TEXT + } + + val isUserDrawing: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT } + val isUserBlurring: Boolean by derivedStateOf { mode == Mode.BLUR } + val isUserEnteringText: Boolean by derivedStateOf { mode == Mode.TEXT } + val isUserInsertingSticker: Boolean by derivedStateOf { mode == Mode.INSERT_STICKER } + + fun requestCancelEdit() { + if (hasUnsavedChanges) { + showDiscardDialog = true + } else { + cancelEdit() + } + } + + fun dismissDiscardDialog() { + showDiscardDialog = false + } + + fun confirmDiscardEdit() { + showDiscardDialog = false + cancelEdit() + } + + fun beginDrawEdit() { + enterDrawMode() + } + + fun beginCropAndRotateEdit() { + enterCropMode() + } + + fun cancelEdit() { + when { + mode == Mode.TEXT -> { + finishTextEditing() + } + mode == Mode.CROP -> { + editorModel.clearUndoStack() + editorModel.doneCrop() + exitEditMode() + } + isInDrawSession -> { + drawSessionSnapshot?.let { editorModel.restoreFromSnapshot(it) } + exitEditMode() + } + else -> exitEditMode() + } + } + + fun commitEdit() { + when (mode) { + Mode.TEXT -> finishTextEditing() + Mode.CROP -> { + editorModel.doneCrop() + exitEditMode() + } + else -> exitEditMode() + } + } + + private fun exitEditMode() { + drawSessionSnapshot = null + drawSessionDirty = false + selectedElement = null + mode = Mode.NONE + imageEditorState.isDrawing = false + imageEditorState.isBlur = false + } + + fun enterDrawMode() { + snapshotIfNewDrawSession() + mode = Mode.DRAW + syncDrawingState() + } + + fun enterHighlightMode() { + snapshotIfNewDrawSession() + mode = Mode.HIGHLIGHT + syncDrawingState() + } + + fun enterBlurMode() { + snapshotIfNewDrawSession() + mode = Mode.BLUR + syncDrawingState() + } + + private fun snapshotIfNewDrawSession() { + if (!isInDrawSession) { + drawSessionSnapshot = editorModel.createSnapshot() + drawSessionDirty = false + } + } + + fun setDrawColor(color: Int) { + imageEditorState.drawColor = color + } + + fun setDrawThickness(thickness: Float) { + imageEditorState.drawThickness = thickness + } + + private fun syncDrawingState() { + imageEditorState.isDrawing = true + imageEditorState.isBlur = mode == Mode.BLUR + imageEditorState.drawCap = if (mode == Mode.HIGHLIGHT) Paint.Cap.SQUARE else Paint.Cap.ROUND + } + + fun enterCropMode() { + editorModel.startCrop() + initialDialScale = editorModel.mainImage?.localScaleX ?: 1f + mode = Mode.CROP + } + + fun enterTextMode() { + snapshotIfNewDrawSession() + val renderer = MultiLineTextRenderer("", textColorBarState.color, MultiLineTextRenderer.Mode.REGULAR) + val element = EditorElement(renderer, EditorModel.Z_TEXT) + editorModel.addElementCentered(element, 1f) + beginTextEditing(element) + } + + private fun beginTextEditing(element: EditorElement) { + mode = Mode.TEXT + textEditingElement = element + imageEditorState.textEditingElement = element + editorModel.addFade() + editorModel.setSelectionVisible(false) + (element.renderer as? MultiLineTextRenderer)?.setFocused(true) + } + + fun finishTextEditing() { + val element = textEditingElement ?: return + val renderer = element.renderer as? MultiLineTextRenderer + val hasText = renderer?.text?.isNotEmpty() == true + val snapshot = drawSessionSnapshot + + renderer?.setFocused(false) + editorModel.zoomOut() + editorModel.removeFade() + editorModel.setSelectionVisible(true) + + if (!hasText && snapshot != null) { + editorModel.restoreFromSnapshot(snapshot) + } + + editorModel.setSelected(null) + textEditingElement = null + imageEditorState.textEditingElement = null + exitEditMode() + } + + fun onTextChanged(text: String) { + val element = textEditingElement ?: return + val renderer = element.renderer as? MultiLineTextRenderer ?: return + renderer.setText(text) + imageEditorState.invalidate() + } + + fun onTextSelectionChanged(selStart: Int, selEnd: Int) { + val element = textEditingElement ?: return + val renderer = element.renderer as? MultiLineTextRenderer ?: return + renderer.setSelection(selStart, selEnd) + editorModel.zoomToTextElement(element, renderer) + imageEditorState.invalidate() + } + + fun setTextColor(color: Int) { + val element = textEditingElement ?: selectedElement + val renderer = element?.renderer as? MultiLineTextRenderer ?: return + renderer.color = color + imageEditorState.invalidate() + } + + fun onEntityTapped(element: EditorElement?) { + if (element != null && element.renderer is SelectableRenderer) { + (element.renderer as SelectableRenderer).onSelected(true) + editorModel.setSelected(element) + selectedElement = element + mode = when (element.renderer) { + is MultiLineTextRenderer -> Mode.MOVE_TEXT + else -> Mode.MOVE_STICKER + } + } else { + clearSelection() + } + } + + private fun clearSelection() { + if (selectedElement != null) { + (selectedElement?.renderer as? SelectableRenderer)?.onSelected(false) + editorModel.setSelected(null) + selectedElement = null + mode = Mode.NONE + imageEditorState.invalidate() + } + } + + fun enterStickerMode() { + mode = Mode.INSERT_STICKER + } + + fun lockCrop() { + editorModel.setCropAspectLock(true) + isCropAspectRatioLocked = true + } + + fun unlockCrop() { + editorModel.setCropAspectLock(false) + isCropAspectRatioLocked = false + } + + fun flip() { + editorModel.flipHorizontal() + } + + fun rotate() { + editorModel.rotate90anticlockwise() + } + + fun onDialGestureStart() { + val mainImage = editorModel.mainImage ?: return + initialDialScale = mainImage.localScaleX + minDialScaleDown = 1f + editorModel.pushUndoPoint() + editorModel.updateUndoRedoAvailabilityState() + initialDialImageDegrees = Math.toDegrees(mainImage.localRotationAngle.toDouble()).toFloat() + } + + fun onDialRotationChanged(degrees: Float) { + editorModel.setMainImageEditorMatrixRotation(degrees - initialDialImageDegrees, minDialScaleDown) + } + + fun onDialGestureEnd() { + val mainImage = editorModel.mainImage ?: return + mainImage.commitEditorMatrix() + editorModel.postEdit(true) + initialDialScale = mainImage.localScaleX + } + + fun toggleImageQuality() { + // TODO + } + + fun saveToDisk() { + // TODO + } + + fun addMedia() { + // TODO + } + + enum class Mode { + NONE, + CROP, + TEXT, + DRAW, + HIGHLIGHT, + BLUR, + MOVE_STICKER, + MOVE_TEXT, + DELETE, + INSERT_STICKER + } + + @Stable + class Container @RememberInComposition constructor() { + private val controllers = SnapshotStateMap() + + fun getOrCreate(uri: Uri, editorModel: EditorModel): ImageController { + return controllers.getOrPut(uri) { ImageController(editorModel) } + } + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index d72df1ed56..c38c6b37a0 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -5,6 +5,7 @@ package org.signal.mediasend.edit +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.gestures.snapping.SnapPosition import androidx.compose.foundation.layout.Arrangement.spacedBy @@ -18,13 +19,19 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.dp +import androidx.fragment.compose.AndroidFragment +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.launch import org.signal.core.ui.WindowBreakpoint import org.signal.core.ui.compose.AllDevicePreviews @@ -33,12 +40,17 @@ import org.signal.core.ui.rememberWindowBreakpoint import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.EditorState import org.signal.mediasend.MediaSendState +import org.signal.mediasend.edit.image.ImageEditor +import org.signal.mediasend.edit.image.ImageEditorToolbar +import org.signal.mediasend.edit.image.RotationDial +import org.signal.mediasend.edit.video.VideoEditorFragment +import org.signal.mediasend.edit.video.VideoEditorToolbar +import org.signal.mediasend.edit.video.VideoEditorViewModel @Composable fun MediaEditScreen( state: MediaSendState, - onEvent: (MediaEditScreenEvent) -> Unit, - videoEditorSlot: @Composable () -> Unit = {} + onEvent: (MediaEditScreenEvent) -> Unit ) { val scope = rememberCoroutineScope() @@ -53,33 +65,48 @@ fun MediaEditScreen( .navigationBarsPadding() ) { val isSmallWindowBreakpoint = rememberWindowBreakpoint() is WindowBreakpoint.Small - val controllers = remember { EditorController.Container() } + val imageControllers = remember { ImageController.Container() } - val currentController = state.focusedMedia?.let { - when (val editorState = state.editorStateMap[it.uri]) { - is EditorState.Image -> controllers.getOrCreateImageController(it.uri, editorState.model) - is EditorState.VideoTrim -> EditorController.VideoTrim - null -> error("Invalid editor state.") - } + val videoEditorViewModel = rememberVideoEditorViewModel() + + val focusedUri = state.focusedMedia?.uri + val focusedEditorState = focusedUri?.let { state.editorStateMap[it] } + val imageController = if (focusedUri != null && focusedEditorState is EditorState.Image) { + imageControllers.getOrCreate(focusedUri, focusedEditorState.model) + } else { + null } + var isVideoInteracting by remember(focusedUri) { mutableStateOf(false) } + val isInteracting = imageController?.isUserInEdit == true || isVideoInteracting + HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), snapPosition = SnapPosition.Center, - userScrollEnabled = currentController?.isUserInEdit != true + userScrollEnabled = !isInteracting ) { index -> val uri = state.selectedMedia[index].uri when (val editorState = state.editorStateMap[uri]) { is EditorState.Image -> { ImageEditor( - controller = controllers.getOrCreateImageController(uri, editorState.model), + controller = imageControllers.getOrCreate(uri, editorState.model), modifier = Modifier.fillMaxSize() ) } is EditorState.VideoTrim -> { - videoEditorSlot() + val media = state.selectedMedia[index] + AndroidFragment( + modifier = Modifier.fillMaxSize(), + arguments = VideoEditorFragment.arguments(media.uri, maxAttachmentSize = 0L, isVideoGif = media.isVideoGif) + ) { fragment -> + fragment.onStateUpdate( + state.focusedMedia?.uri, + state.isTouchEnabled, + state::getOrCreateVideoTrimData + ) + } } null -> { @@ -92,7 +119,7 @@ fun MediaEditScreen( } } - val isTextEditing = currentController is EditorController.Image && currentController.textEditingElement != null + val isTextEditing = imageController?.textEditingElement != null Column( verticalArrangement = spacedBy(20.dp), @@ -101,7 +128,7 @@ fun MediaEditScreen( .align(Alignment.BottomCenter) .then(if (isTextEditing) Modifier.imePadding() else Modifier) ) { - if (state.selectedMedia.isNotEmpty() && currentController?.isUserInEdit != true) { + if (state.selectedMedia.isNotEmpty() && !isInteracting) { ThumbnailRow( selectedMedia = state.selectedMedia, pagerState = pagerState, @@ -116,24 +143,60 @@ fun MediaEditScreen( ) } - when (currentController) { - is EditorController.Image -> { - if (currentController.mode == EditorController.Image.Mode.CROP) { - RotationDial( - imageEditorController = currentController, - modifier = Modifier - .widthIn(max = 380.dp) - .padding(horizontal = 16.dp) - ) - } - if (isSmallWindowBreakpoint) { - ImageEditorToolbar(imageEditorController = currentController) + when (focusedEditorState) { + is EditorState.Image -> { + imageController?.let { controller -> + if (controller.mode == ImageController.Mode.CROP) { + RotationDial( + imageEditorController = controller, + modifier = Modifier + .widthIn(max = 380.dp) + .padding(horizontal = 16.dp) + ) + } + if (isSmallWindowBreakpoint) { + ImageEditorToolbar(imageEditorController = controller) + } } } - is EditorController.VideoTrim, null -> Unit + is EditorState.VideoTrim -> { + val playbackPositionUs by produceState(focusedEditorState.videoTrimData.startTimeUs, focusedUri) { + videoEditorViewModel.events(focusedUri).collect { event -> + if (event is VideoEditorViewModel.Event.ActualPositionChanged) { + value = event.positionUs + } + } + } + + VideoEditorToolbar( + videoTrimData = focusedEditorState.videoTrimData, + playbackPositionUs = playbackPositionUs, + onEvent = { event -> + when (event) { + is MediaEditScreenEvent.VideoTrimChanged -> { + isVideoInteracting = !event.editingComplete + onEvent(event) + } + is MediaEditScreenEvent.VideoSeek -> { + isVideoInteracting = !event.editingComplete + videoEditorViewModel.sendCommand( + focusedUri, + if (event.editingComplete) { + VideoEditorViewModel.Command.EndPositionDrag(event.positionUs) + } else { + VideoEditorViewModel.Command.PositionDrag(event.positionUs) + } + ) + } + else -> onEvent(event) + } + } + ) + } + null -> Unit } - if (currentController?.isUserInEdit != true) { + if (!isInteracting) { AddAMessageRow( message = state.message, onEvent = onEvent, @@ -146,9 +209,9 @@ fun MediaEditScreen( } } - if (!isSmallWindowBreakpoint && currentController is EditorController.Image) { + if (!isSmallWindowBreakpoint && imageController != null) { ImageEditorToolbar( - imageEditorController = currentController, + imageEditorController = imageController, modifier = Modifier .align(Alignment.CenterEnd) .padding(end = 24.dp) @@ -158,6 +221,15 @@ fun MediaEditScreen( } } +@Composable +private fun rememberVideoEditorViewModel(): VideoEditorViewModel { + return if (LocalInspectionMode.current) { + remember { VideoEditorViewModel() } + } else { + viewModel(viewModelStoreOwner = LocalActivity.current as ViewModelStoreOwner) + } +} + @AllDevicePreviews @Composable private fun MediaEditScreenPreview() { @@ -172,14 +244,7 @@ private fun MediaEditScreenPreview() { selectedMedia.first().uri to EditorState.Image(EditorModel.create(0)) ) ), - onEvent = {}, - videoEditorSlot = { - Box( - modifier = Modifier - .fillMaxSize() - .background(color = Color.Red) - ) - } + onEvent = {} ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaSendDialogs.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt similarity index 91% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/MediaSendDialogs.kt rename to feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt index 8e7edb7cc2..21881e90c3 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaSendDialogs.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt @@ -12,7 +12,7 @@ import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.mediasend.R -object MediaSendDialogs { +object MediaEditScreenDialogs { @Composable fun DiscardEditsConfirmationDialog( @@ -34,7 +34,7 @@ object MediaSendDialogs { @Composable private fun DiscardEditsConfirmationDialogPreview() { Previews.Preview { - MediaSendDialogs.DiscardEditsConfirmationDialog( + MediaEditScreenDialogs.DiscardEditsConfirmationDialog( onDiscard = {}, onDismiss = {} ) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt index dd2deb197e..a9d2ba4662 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt @@ -6,9 +6,12 @@ package org.signal.mediasend.edit import org.signal.core.models.media.Media +import org.signal.mediasend.edit.video.VideoTrimData sealed interface MediaEditScreenEvent { data class FocusedMediaChanged(val media: Media) : MediaEditScreenEvent data class AddMessageClick(val startWithEmojiKeyboard: Boolean = false) : MediaEditScreenEvent data object NavigateToSend : MediaEditScreenEvent + data class VideoTrimChanged(val videoTrimData: VideoTrimData, val editingComplete: Boolean) : MediaEditScreenEvent + data class VideoSeek(val positionUs: Long, val editingComplete: Boolean) : MediaEditScreenEvent } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/HSVColorBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/HSVColorBar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt index 2df18378ae..baf4592574 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/HSVColorBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.edit.image import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt similarity index 95% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt rename to feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt index bafd3404bf..9be8c5b7ce 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.edit.image import android.graphics.PointF import androidx.compose.foundation.Canvas @@ -36,10 +36,11 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import org.signal.imageeditor.core.ImageEditorTouchHandler +import org.signal.mediasend.edit.ImageController @Composable fun ImageEditor( - controller: EditorController.Image, + controller: ImageController, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -78,7 +79,7 @@ fun ImageEditor( } @Composable -private fun HiddenTextInput(controller: EditorController.Image) { +private fun HiddenTextInput(controller: ImageController) { var text by remember { mutableStateOf(TextFieldValue("")) } val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current @@ -106,7 +107,7 @@ private fun HiddenTextInput(controller: EditorController.Image) { } } -private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: EditorController.Image): Modifier { +private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: ImageController): Modifier { return this.pointerInput(controller, controller.textEditingElement) { val touchHandler = ImageEditorTouchHandler() @@ -137,7 +138,7 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller } // In NONE mode with nothing hit, let the pager handle the gesture - if (controller.mode == EditorController.Image.Mode.NONE && !touchHandler.hasActiveSession()) { + if (controller.mode == ImageController.Mode.NONE && !touchHandler.hasActiveSession()) { return@awaitEachGesture } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt rename to feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt index 828146304b..b71c991203 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.edit.image import android.content.Context import android.graphics.Canvas diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt similarity index 92% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt index 4d29d59cb0..1821e0da57 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.edit.image import androidx.compose.animation.Crossfade import androidx.compose.foundation.background @@ -36,11 +36,13 @@ import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.ui.rememberWindowBreakpoint import org.signal.core.util.next import org.signal.imageeditor.core.model.EditorModel +import org.signal.mediasend.edit.ImageController +import org.signal.mediasend.edit.MediaEditScreenDialogs import java.util.EnumMap @Composable fun ImageEditorToolbar( - imageEditorController: EditorController.Image, + imageEditorController: ImageController, modifier: Modifier = Modifier ) { when { @@ -51,10 +53,10 @@ fun ImageEditorToolbar( modifier = modifier ) } - imageEditorController.mode == EditorController.Image.Mode.NONE -> { + imageEditorController.mode == ImageController.Mode.NONE -> { ImageEditorNoneStateToolbar(imageEditorController, modifier) } - imageEditorController.mode == EditorController.Image.Mode.CROP -> { + imageEditorController.mode == ImageController.Mode.CROP -> { ImageEditorCropAndResizeToolbar(imageEditorController, modifier) } else -> { @@ -68,7 +70,7 @@ fun ImageEditorToolbar( */ @Composable private fun ImageEditorNoneStateToolbar( - imageEditorController: EditorController.Image, + imageEditorController: ImageController, modifier: Modifier = Modifier ) { OrientedImageEditorToolbar(modifier) { @@ -101,7 +103,7 @@ private fun ImageEditorNoneStateToolbar( @Composable private fun ImageEditorDrawStateToolbar( - imageEditorController: EditorController.Image, + imageEditorController: ImageController, modifier: Modifier = Modifier ) { OrientedImageEditorToolbar( @@ -157,7 +159,7 @@ private fun ImageEditorDrawStateToolbar( @Composable private fun ImageEditorCropAndResizeToolbar( - imageEditorController: EditorController.Image, + imageEditorController: ImageController, modifier: Modifier = Modifier ) { OrientedImageEditorToolbar( @@ -203,7 +205,7 @@ private fun ImageEditorCropAndResizeToolbar( } @Composable -private fun CommitButton(imageEditorController: EditorController.Image) { +private fun CommitButton(imageEditorController: ImageController) { ImageEditorButton( imageVector = SignalIcons.Check.imageVector, onClick = imageEditorController::commitEdit, @@ -214,9 +216,9 @@ private fun CommitButton(imageEditorController: EditorController.Image) { } @Composable -private fun DiscardButton(imageEditorController: EditorController.Image) { +private fun DiscardButton(imageEditorController: ImageController) { if (imageEditorController.showDiscardDialog) { - MediaSendDialogs.DiscardEditsConfirmationDialog( + MediaEditScreenDialogs.DiscardEditsConfirmationDialog( onDiscard = imageEditorController::confirmDiscardEdit, onDismiss = imageEditorController::dismissDiscardDialog ) @@ -334,7 +336,7 @@ private fun ImageEditorNoneStateToolbarPreview() { Previews.Preview { ImageEditorNoneStateToolbar( imageEditorController = remember { - EditorController.Image(EditorModel.create(0)) + ImageController(EditorModel.create(0)) } ) } @@ -349,7 +351,7 @@ private fun ImageEditorDrawStateToolbarPreview() { Previews.Preview { ImageEditorDrawStateToolbar( imageEditorController = remember { - EditorController.Image(EditorModel.create(0)).apply { + ImageController(EditorModel.create(0)).apply { enterDrawMode() } } @@ -366,7 +368,7 @@ private fun ImageEditorCropAndResizeToolbarPreview() { Previews.Preview { ImageEditorCropAndResizeToolbar( imageEditorController = remember { - EditorController.Image(EditorModel.create(0)).apply { + ImageController(EditorModel.create(0)).apply { enterCropMode() } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/RotationDial.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/RotationDial.kt similarity index 97% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/RotationDial.kt rename to feature/media-send/src/main/java/org/signal/mediasend/edit/image/RotationDial.kt index 18ef64d810..b26931ace7 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/RotationDial.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/RotationDial.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.edit.image import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.sp import org.signal.core.ui.compose.Dividers import org.signal.core.ui.compose.Previews import org.signal.imageeditor.core.model.EditorModel +import org.signal.mediasend.edit.ImageController import kotlin.math.abs import kotlin.math.ceil import kotlin.math.floor @@ -58,7 +59,7 @@ private val MAJOR_INDICATOR_HEIGHT = 24.dp @Composable fun RotationDial( - imageEditorController: EditorController.Image, + imageEditorController: ImageController, modifier: Modifier = Modifier ) { val hapticFeedback = LocalHapticFeedback.current @@ -239,7 +240,7 @@ fun RotationDialPreview() { Previews.Preview { RotationDial( imageEditorController = remember { - EditorController.Image(EditorModel.create(0)) + ImageController(EditorModel.create(0)) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorFragment.kt similarity index 55% rename from app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt rename to feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorFragment.kt index cb279dba10..4f31fc968c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorFragment.kt @@ -1,33 +1,35 @@ -package org.thoughtcrime.securesms.mediasend +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.video import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import org.signal.core.util.Throttler +import org.signal.core.util.getParcelableCompat import org.signal.core.util.logging.Log -import org.signal.mediasend.MediaConstraints -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel -import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData -import org.thoughtcrime.securesms.mms.VideoSlide -import org.thoughtcrime.securesms.scribbles.VideoEditorPlayButtonLayout -import org.thoughtcrime.securesms.util.visible -import org.thoughtcrime.securesms.video.VideoPlayer -import org.thoughtcrime.securesms.video.VideoPlayer.PlayerCallback -import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView -import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView.PositionDragListener -import java.io.IOException +import org.signal.mediasend.MediaSendDependencies +import org.signal.mediasend.R +import org.signal.video.VideoPlayer import kotlin.time.Duration.Companion.microseconds import kotlin.time.Duration.Companion.milliseconds -class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragment { - private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() }) +class VideoEditorFragment : Fragment() { private val videoScanThrottle = Throttler(150) private val handler = Handler(Looper.getMainLooper()) @@ -37,17 +39,16 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm private var wasPlayingBeforeEdit = false private var maxSend: Long = 0 private lateinit var uri: Uri - private lateinit var controller: Controller + private lateinit var viewModel: VideoEditorViewModel private lateinit var player: VideoPlayer private lateinit var hud: VideoEditorPlayButtonLayout - private lateinit var videoTimeLine: VideoThumbnailsRangeSelectorView private val updatePosition = object : Runnable { override fun run() { - if (MediaConstraints.isVideoTranscodeAvailable()) { + if (IS_VIDEO_TRANSCODE_AVAILABLE) { val playbackPosition = player.truePlaybackPosition if (playbackPosition >= 0) { - videoTimeLine.setActualPosition(playbackPosition.milliseconds.inWholeMicroseconds) + viewModel.emitEvent(uri, VideoEditorViewModel.Event.ActualPositionChanged(playbackPosition.milliseconds.inWholeMicroseconds)) handler.postDelayed(this, 100) } } @@ -56,13 +57,7 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - controller = if (activity is Controller) { - activity as Controller - } else if (parentFragment is Controller) { - parentFragment as Controller - } else { - throw IllegalStateException("Parent must implement Controller interface.") - } + viewModel = ViewModelProvider(requireActivity())[VideoEditorViewModel::class.java] } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -72,31 +67,29 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - videoTimeLine = requireActivity().findViewById(R.id.video_timeline) - player = view.findViewById(R.id.video_player) hud = view.findViewById(R.id.video_editor_hud) - uri = requireArguments().getParcelable(KEY_URI)!! + uri = requireArguments().getParcelableCompat(KEY_URI, Uri::class.java)!! isVideoGif = requireArguments().getBoolean(KEY_IS_VIDEO_GIF) maxSend = requireArguments().getLong(KEY_MAX_SEND) - val slide = VideoSlide(requireContext(), uri, 0, isVideoGif) player.setWindow(requireActivity().window) - player.setVideoSource(slide, isVideoGif, TAG) + player.setExoPlayerPool(MediaSendDependencies.exoPlayerPool) + player.setVideoSource(uri, isVideoGif, TAG) - hud.visible = !slide.isVideoGif + hud.isVisible = !isVideoGif - if (slide.isVideoGif) { - player.setPlayerCallback(object : PlayerCallback { + if (isVideoGif) { + player.setPlayerCallback(object : VideoPlayer.PlayerCallback { override fun onPlaying() { - controller.onPlayerReady() + viewModel.emitEvent(uri, VideoEditorViewModel.Event.PlayerReady) } override fun onStopped() = Unit override fun onError(e: Exception) { - controller.onPlayerError() + viewModel.emitEvent(uri, VideoEditorViewModel.Event.PlayerError) } }) player.hideControls() @@ -113,9 +106,9 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm hud.showPlayButton() } - player.setPlayerCallback(object : PlayerCallback { + player.setPlayerCallback(object : VideoPlayer.PlayerCallback { override fun onReady() { - controller.onPlayerReady() + viewModel.emitEvent(uri, VideoEditorViewModel.Event.PlayerReady) } override fun onPlaying() { @@ -127,68 +120,59 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm } override fun onError(e: Exception) { - controller.onPlayerError() + viewModel.emitEvent(uri, VideoEditorViewModel.Event.PlayerError) } }) } - sharedViewModel.state.observe(viewLifecycleOwner) { incomingState -> - val focusedUri = incomingState.focusedMedia?.uri - val currentlyFocused = focusedUri != null && focusedUri == uri - if (MediaConstraints.isVideoTranscodeAvailable()) { - if (currentlyFocused) { - if (isVideoGif) { - player.play() - } else { - if (!isFocused) { - bindVideoTimeline(incomingState.getOrCreateVideoTrimData(uri)) - } else { - val videoTrimData = if (focusedUri != null) { - incomingState.getOrCreateVideoTrimData(focusedUri) - } else { - VideoTrimData() - } - hud.visible = incomingState.isTouchEnabled && !isVideoGif - onEditVideoDuration(videoTrimData, incomingState.isTouchEnabled) - } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.commands(uri).collect { command -> + when (command) { + is VideoEditorViewModel.Command.PositionDrag -> onSeek(command.positionUs, dragComplete = false) + is VideoEditorViewModel.Command.EndPositionDrag -> onSeek(command.positionUs, dragComplete = true) } - } else { - stopPositionUpdates() - player.pause() } } - isFocused = currentlyFocused } } + fun onStateUpdate(focusedUri: Uri?, isTouchEnabled: Boolean, getOrCreateVideoTrimData: (Uri) -> VideoTrimData) { + val currentlyFocused = focusedUri != null && focusedUri == uri + if (IS_VIDEO_TRANSCODE_AVAILABLE) { + if (currentlyFocused) { + if (isVideoGif) { + player.play() + } else { + if (!isFocused) { + bindVideoTimeline(getOrCreateVideoTrimData(uri)) + } else { + val videoTrimData = getOrCreateVideoTrimData(focusedUri) + hud.isVisible = isTouchEnabled && !isVideoGif + onEditVideoDuration(videoTrimData, isTouchEnabled) + } + } + } else { + stopPositionUpdates() + player.pause() + } + } + isFocused = currentlyFocused + } + private fun bindVideoTimeline(data: VideoTrimData) { val autoplay = isVideoGif - val slide = VideoSlide(requireContext(), uri, 0, autoplay) if (data.isDurationEdited) { player.clip(data.startTimeUs, data.endTimeUs, autoplay) } - if (slide.hasVideo() && !autoplay) { - try { - videoTimeLine.registerPlayerDragListener(this) - - hud.visibility = View.VISIBLE - startPositionUpdates() - } catch (e: IOException) { - Log.w(TAG, e) - } + if (!autoplay) { + hud.visibility = View.VISIBLE + startPositionUpdates() } } - override fun onPositionDrag(position: Long) { - onSeek(position, false) - } - - override fun onEndPositionDrag(position: Long) { - onSeek(position, true) - } - override fun onDestroyView() { super.onDestroyView() @@ -226,19 +210,7 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm } } - override fun setUri(uri: Uri) { - this.uri = uri - } - - override fun getUri(): Uri { - return uri - } - - override fun saveState(): Any = Unit - - override fun restoreState(state: Any) = Unit - - override fun notifyHidden() { + private fun notifyHidden() { pausePlayback() } @@ -295,31 +267,27 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm } } - interface Controller { - fun onPlayerReady() - - fun onPlayerError() - - fun onTouchEventsNeeded(needed: Boolean) - } - companion object { private val TAG = Log.tag(VideoEditorFragment::class.java) + private val IS_VIDEO_TRANSCODE_AVAILABLE = Build.VERSION.SDK_INT >= 26 + private const val KEY_URI = "uri" private const val KEY_MAX_SEND = "max_send_size" private const val KEY_IS_VIDEO_GIF = "is_video_gif" - fun newInstance(uri: Uri, maxAttachmentSize: Long, isVideoGif: Boolean): VideoEditorFragment { - val args = Bundle() - args.putParcelable(KEY_URI, uri) - args.putLong(KEY_MAX_SEND, maxAttachmentSize) - args.putBoolean(KEY_IS_VIDEO_GIF, isVideoGif) + fun arguments(uri: Uri, maxAttachmentSize: Long, isVideoGif: Boolean): Bundle { + return Bundle().apply { + putParcelable(KEY_URI, uri) + putLong(KEY_MAX_SEND, maxAttachmentSize) + putBoolean(KEY_IS_VIDEO_GIF, isVideoGif) + } + } - val fragment = VideoEditorFragment() - fragment.arguments = args - fragment.setUri(uri) - return fragment + fun newInstance(uri: Uri, maxAttachmentSize: Long, isVideoGif: Boolean): VideoEditorFragment { + return VideoEditorFragment().apply { + arguments = arguments(uri, maxAttachmentSize, isVideoGif) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorPlayButtonLayout.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorPlayButtonLayout.kt similarity index 82% rename from app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorPlayButtonLayout.kt rename to feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorPlayButtonLayout.kt index ef79d466df..35b2a2c7c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorPlayButtonLayout.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorPlayButtonLayout.kt @@ -1,13 +1,18 @@ -package org.thoughtcrime.securesms.scribbles +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.video import android.animation.Animator import android.content.Context import android.util.AttributeSet import android.view.View import android.widget.FrameLayout -import org.signal.core.util.logging.Log.tag -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.util.createDefaultCubicBezierInterpolator +import org.signal.core.ui.view.createDefaultCubicBezierInterpolator +import org.signal.core.util.logging.Log +import org.signal.mediasend.R /** * The play button overlay for controlling playback in the video editor. @@ -35,6 +40,7 @@ class VideoEditorPlayButtonLayout @JvmOverloads constructor(context: Context, at override fun onAnimationEnd(animation: Animator) { playOverlay.visibility = GONE } + override fun onAnimationStart(animation: Animator) = Unit override fun onAnimationCancel(animation: Animator) = Unit override fun onAnimationRepeat(animation: Animator) = Unit @@ -52,6 +58,6 @@ class VideoEditorPlayButtonLayout @JvmOverloads constructor(context: Context, at companion object { @Suppress("unused") - private val TAG = tag(VideoEditorPlayButtonLayout::class.java) + private val TAG = Log.tag(VideoEditorPlayButtonLayout::class.java) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorToolbar.kt new file mode 100644 index 0000000000..f57e5b0b59 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorToolbar.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.video + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidView +import org.signal.core.ui.compose.Previews +import org.signal.mediasend.edit.MediaEditScreenEvent + +/** + * Timeline/trim toolbar for a video. Trim data and the current playback position are owned elsewhere and passed in; + * all user-driven changes are reported back through [onEvent] — [MediaEditScreenEvent.VideoTrimChanged] for trim + * handles and [MediaEditScreenEvent.VideoSeek] for scrubbing the playback position. + */ +@Composable +fun VideoEditorToolbar( + videoTrimData: VideoTrimData, + playbackPositionUs: Long = 0L, + onEvent: (MediaEditScreenEvent) -> Unit = {} +) { + val currentOnEvent by rememberUpdatedState(onEvent) + var isDragging by remember { mutableStateOf(false) } + + val positionDragListener = remember { + object : VideoThumbnailsRangeSelectorView.PositionDragListener { + override fun onPositionDrag(position: Long) { + isDragging = true + currentOnEvent(MediaEditScreenEvent.VideoSeek(positionUs = position, editingComplete = false)) + } + + override fun onEndPositionDrag(position: Long) { + isDragging = false + currentOnEvent(MediaEditScreenEvent.VideoSeek(positionUs = position, editingComplete = true)) + } + } + } + + val rangeDragListener = remember { + VideoThumbnailsRangeSelectorView.RangeDragListener { minValue, maxValue, duration, end -> + isDragging = !end + currentOnEvent( + MediaEditScreenEvent.VideoTrimChanged( + videoTrimData = VideoTrimData( + isDurationEdited = minValue > 0 || maxValue < duration, + totalInputDurationUs = duration, + startTimeUs = minValue, + endTimeUs = maxValue + ), + editingComplete = end + ) + ) + } + } + + AndroidView( + factory = { VideoThumbnailsRangeSelectorView(it) }, + update = { selectorView -> + selectorView.registerPlayerDragListener(positionDragListener) + selectorView.registerEditorOnRangeChangeListener(rangeDragListener) + + // The view owns its state during a drag and emits changes outward, so only push the authoritative range and + // playback position back in when the user isn't actively dragging — otherwise we'd fight the live gesture. + if (!isDragging) { + selectorView.setRange(videoTrimData.startTimeUs, videoTrimData.endTimeUs) + selectorView.setActualPosition(playbackPositionUs) + } + }, + onRelease = { selectorView -> + selectorView.unregisterDragListener() + selectorView.registerEditorOnRangeChangeListener(null) + } + ) +} + +@Preview +@Composable +fun VideoEditorToolbarPreview() { + Previews.Preview { + VideoEditorToolbar( + videoTrimData = VideoTrimData(isDurationEdited = false) + ) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorViewModel.kt new file mode 100644 index 0000000000..b84259b93a --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorViewModel.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.video + +import android.net.Uri +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * Activity-scoped, stateless event bus that decouples [VideoEditorFragment] from whichever host + * (the v2 review fragment or the v3 Compose editor) is driving it. It replaces the old + * `VideoEditorFragment.Controller` interface with two directions of [Channel]: + * + * - [events]: emitted by the fragment, consumed by the host (player lifecycle + playback position). + * - [commands]: emitted by the host, consumed by the fragment (timeline scrub seeks). + * + * Channels are keyed by media [Uri] so that, when several video pages are alive at once, each + * fragment and host only ever see signals for their own video. + */ +class VideoEditorViewModel : ViewModel() { + + private val eventChannels = mutableMapOf>() + private val commandChannels = mutableMapOf>() + + fun events(uri: Uri): Flow = eventChannel(uri).receiveAsFlow() + + fun commands(uri: Uri): Flow = commandChannel(uri).receiveAsFlow() + + fun emitEvent(uri: Uri, event: Event) { + eventChannel(uri).trySend(event) + } + + fun sendCommand(uri: Uri, command: Command) { + commandChannel(uri).trySend(command) + } + + private fun eventChannel(uri: Uri): Channel = eventChannels.getOrPut(uri) { Channel(Channel.BUFFERED) } + + private fun commandChannel(uri: Uri): Channel = commandChannels.getOrPut(uri) { Channel(Channel.BUFFERED) } + + /** Signals from the fragment to the host. */ + sealed interface Event { + data object PlayerReady : Event + data object PlayerError : Event + data class TouchEventsNeeded(val needed: Boolean) : Event + data class ActualPositionChanged(val positionUs: Long) : Event + } + + /** Signals from the host to the fragment. */ + sealed interface Command { + data class PositionDrag(val positionUs: Long) : Command + data class EndPositionDrag(val positionUs: Long) : Command + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsRangeSelectorView.java similarity index 96% rename from app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java rename to feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsRangeSelectorView.java index 899e0e90d6..e630876864 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsRangeSelectorView.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.video.videoconverter; +package org.signal.mediasend.edit.video; import android.content.Context; import android.content.res.TypedArray; @@ -20,14 +20,12 @@ import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; -import androidx.annotation.RequiresApi; import org.signal.core.util.DimensionUnit; import java.util.Locale; import java.util.concurrent.TimeUnit; -@RequiresApi(api = 23) public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView { private static final long MINIMUM_SELECTABLE_RANGE = TimeUnit.MILLISECONDS.toMicros(500); @@ -145,6 +143,19 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView this.playerDragListener = null; } + /** + * Resets the selected range when the underlying video changes so a newly-set video does not inherit the previous + * video's trim. Without this the retained {@link #minValue}/{@link #maxValue} get re-emitted via + * {@link #afterDurationChange(long)} as the new video's default clip. + */ + @Override + protected void onInputChanged() { + this.minValue = null; + this.maxValue = null; + this.externalMinValue = null; + this.externalMaxValue = null; + } + public void setActualPosition(long position) { if (this.thumbPosition != position) { this.thumbPosition = position; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsView.java similarity index 91% rename from app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java rename to feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsView.java index 56cf9ffdf5..cbc84dd752 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsView.java @@ -1,4 +1,9 @@ -package org.thoughtcrime.securesms.video.videoconverter; +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.video; import android.content.Context; import android.graphics.Bitmap; @@ -14,12 +19,12 @@ import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import org.signal.core.util.DimensionUnit; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.media.DecryptableUriMediaInput; import org.thoughtcrime.securesms.video.interfaces.MediaInput; +import org.thoughtcrime.securesms.video.interfaces.MediaInputFactory; +import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsExtractor; import java.io.IOException; import java.lang.ref.WeakReference; @@ -27,7 +32,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -@RequiresApi(api = 23) abstract public class VideoThumbnailsView extends View { private static final String TAG = Log.tag(VideoThumbnailsView.class); @@ -62,18 +66,19 @@ abstract public class VideoThumbnailsView extends View { /** * @return Whether or not the current URI was changed. */ - public boolean setInput(@NonNull Uri uri) throws IOException { + public boolean setInput(@NonNull Uri uri, @NonNull MediaInputFactory mediaInputFactory) throws IOException { if (uri.equals(this.currentUri)) { return false; } this.currentUri = uri; - this.input = DecryptableUriMediaInput.createForUri(getContext(), uri); + this.input = mediaInputFactory.createForUri(getContext(), uri); this.thumbnails = null; if (thumbnailsTask != null) { thumbnailsTask.cancel(true); thumbnailsTask = null; } + onInputChanged(); invalidate(); return true; } @@ -181,6 +186,12 @@ abstract public class VideoThumbnailsView extends View { abstract void afterDurationChange(long duration); + /** + * Invoked when {@link #setInput} swaps in a different video. Subclasses override to drop any per-video state so it + * is not carried over to the new input. + */ + protected void onInputChanged() {} + public long getDuration() { return duration; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/VideoTrimData.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoTrimData.kt similarity index 87% rename from app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/VideoTrimData.kt rename to feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoTrimData.kt index 2dec255b28..d6d4d0fce5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/VideoTrimData.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoTrimData.kt @@ -1,23 +1,26 @@ /* - * Copyright 2024 Signal Messenger, LLC + * Copyright 2026 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.mediasend.v2.videos +package org.signal.mediasend.edit.video import android.os.Bundle +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlin.time.Duration import kotlin.time.Duration.Companion.microseconds /** * This represents the editor state for the video trimmer. */ +@Parcelize data class VideoTrimData( val isDurationEdited: Boolean = false, val totalInputDurationUs: Long = 0, val startTimeUs: Long = 0, val endTimeUs: Long = 0 -) { +) : Parcelable { fun getDuration(): Duration = (endTimeUs - startTimeUs).microseconds diff --git a/app/src/main/res/layout/mediasend_video_fragment.xml b/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml similarity index 75% rename from app/src/main/res/layout/mediasend_video_fragment.xml rename to feature/media-send/src/main/res/layout/mediasend_video_fragment.xml index 6e29be3dda..45d49dc6b6 100644 --- a/app/src/main/res/layout/mediasend_video_fragment.xml +++ b/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml @@ -1,16 +1,21 @@ + + - - + + @@ -28,7 +33,7 @@ android:layout_width="34dp" android:layout_height="34dp" android:layout_gravity="center" - android:contentDescription="@string/ThumbnailView_Play_video_description" + android:contentDescription="@string/VideoEditorHud_play_video_description" android:scaleType="fitCenter" app:srcCompat="@drawable/exo_icon_play" /> diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index c99c0a239f..0d8008cd5c 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -26,4 +26,6 @@ Camera Text Story + + Play video diff --git a/lib/video/build.gradle.kts b/lib/video/build.gradle.kts index 3b2e55d7f8..b579a77834 100644 --- a/lib/video/build.gradle.kts +++ b/lib/video/build.gradle.kts @@ -10,6 +10,8 @@ dependencies { implementation(project(":core:util")) implementation(libs.libsignal.android) implementation(libs.google.guava.android) + implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.exoplayer) implementation(libs.bundles.mp4parser) { exclude(group = "junit", module = "junit") diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/lib/video/src/main/java/org/signal/video/VideoPlayer.java similarity index 90% rename from app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java rename to lib/video/src/main/java/org/signal/video/VideoPlayer.java index 657ecd8a47..da00d94659 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java +++ b/lib/video/src/main/java/org/signal/video/VideoPlayer.java @@ -1,26 +1,15 @@ /* - * Copyright (C) 2017 Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.video; +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; @@ -51,10 +40,7 @@ import androidx.media3.ui.PlayerView; import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.incrementalmac.InvalidMacException; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.dependencies.AppDependencies; -import org.thoughtcrime.securesms.mediapreview.MediaPreviewPlayerControlView; -import org.thoughtcrime.securesms.mms.VideoSlide; +import org.signal.video.exo.ExoPlayerPool; import java.io.IOException; import java.util.Objects; @@ -70,6 +56,7 @@ public class VideoPlayer extends FrameLayout { private final DefaultMediaSourceFactory mediaSourceFactory; private ExoPlayer exoPlayer; + private ExoPlayerPool exoPlayerPool; private LegacyPlayerControlView exoControls; private Window window; private PlayerStateCallback playerStateCallback; @@ -231,13 +218,17 @@ public class VideoPlayer extends FrameLayout { private MediaItem mediaItem; - public void setVideoSource(@NonNull VideoSlide videoSource, boolean autoplay, String poolTag) { - setVideoSource(videoSource, autoplay, poolTag, 0, 0); + public void setExoPlayerPool(ExoPlayerPool exoPlayerPool) { + this.exoPlayerPool = exoPlayerPool; } - public void setVideoSource(@NonNull VideoSlide videoSource, boolean autoplay, String poolTag, long clipStartMs, long clipEndMs) { + 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 = AppDependencies.getExoPlayerPool().require(poolTag); + exoPlayer = exoPlayerPool.require(poolTag); exoPlayer.addListener(exoPlayerListener); exoPlayer.addListener(playerListener); exoPlayer.addAnalyticsListener(analyticsListener); @@ -248,7 +239,7 @@ public class VideoPlayer extends FrameLayout { } } - mediaItem = MediaItem.fromUri(Objects.requireNonNull(videoSource.getUri())).buildUpon() + mediaItem = MediaItem.fromUri(Objects.requireNonNull(uri)).buildUpon() .setClippingConfiguration(getClippingConfiguration(clipStartMs, clipEndMs)) .build(); @@ -327,7 +318,7 @@ public class VideoPlayer extends FrameLayout { return this.exoControls; } - public void setControlView(MediaPreviewPlayerControlView controller) { + public void setControlView(LegacyPlayerControlView controller) { exoControls = controller; exoControls.setPlayer(exoPlayer); } @@ -352,7 +343,7 @@ public class VideoPlayer extends FrameLayout { exoPlayer.removeListener(playerListener); exoPlayer.removeListener(exoPlayerListener); - AppDependencies.getExoPlayerPool().pool(exoPlayer); + exoPlayerPool.pool(exoPlayer); this.exoPlayer = null; } } diff --git a/lib/video/src/main/java/org/signal/video/exo/ExoPlayerExtensions.kt b/lib/video/src/main/java/org/signal/video/exo/ExoPlayerExtensions.kt new file mode 100644 index 0000000000..ae56f77ad4 --- /dev/null +++ b/lib/video/src/main/java/org/signal/video/exo/ExoPlayerExtensions.kt @@ -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() +} diff --git a/lib/video/src/main/java/org/signal/video/exo/ExoPlayerPool.kt b/lib/video/src/main/java/org/signal/video/exo/ExoPlayerPool.kt new file mode 100644 index 0000000000..c0a8b5f83d --- /dev/null +++ b/lib/video/src/main/java/org/signal/video/exo/ExoPlayerPool.kt @@ -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( + private val maximumReservedPlayers: Int +) : AppForegroundObserver.Listener { + + companion object { + private val TAG = Log.tag(ExoPlayerPool::class.java) + } + + 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(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 + ) + + 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 + } +} diff --git a/lib/video/src/main/java/org/thoughtcrime/securesms/video/interfaces/MediaInputFactory.kt b/lib/video/src/main/java/org/thoughtcrime/securesms/video/interfaces/MediaInputFactory.kt new file mode 100644 index 0000000000..57e99842ab --- /dev/null +++ b/lib/video/src/main/java/org/thoughtcrime/securesms/video/interfaces/MediaInputFactory.kt @@ -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 +} diff --git a/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java b/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java index 669f8a9d46..f25aa3f2cc 100644 --- a/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java +++ b/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java @@ -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; diff --git a/app/src/main/res/layout/media_preview_exoplayer_layout.xml b/lib/video/src/main/res/layout/media_preview_exoplayer_layout.xml similarity index 84% rename from app/src/main/res/layout/media_preview_exoplayer_layout.xml rename to lib/video/src/main/res/layout/media_preview_exoplayer_layout.xml index bf9f83f25c..b4c0b8736f 100644 --- a/app/src/main/res/layout/media_preview_exoplayer_layout.xml +++ b/lib/video/src/main/res/layout/media_preview_exoplayer_layout.xml @@ -1,4 +1,9 @@ + + + + + + + + + + \ No newline at end of file