Move the VideoEditorFragment to the media-send feature module.

This commit is contained in:
Alex Hart
2026-06-17 16:59:08 -03:00
committed by Greyson Parrelli
parent 987f92245d
commit 83cb48d119
68 changed files with 1235 additions and 910 deletions
@@ -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];
@@ -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<ExoPlayer> 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<ExoPlayer>
fun provideAndroidCallAudioManager(): AudioManagerCompat
fun provideDonationsService(donationsApi: DonationsApi): DonationsService
fun provideProfileService(profileOperations: ClientZkProfileOperations, authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): ProfileService
@@ -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<ExoPlayer> provideExoPlayerPool() {
return new SimpleExoPlayerPool(context);
}
@@ -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<ExoPlayer> = AppDependencies.exoPlayerPool
}
@@ -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.
@@ -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))
}
@@ -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.
@@ -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);
@@ -1,325 +0,0 @@
package org.thoughtcrime.securesms.mediasend
import android.net.Uri
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.fragment.app.Fragment
import androidx.fragment.app.viewModels
import org.signal.core.util.Throttler
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 kotlin.time.Duration.Companion.microseconds
import kotlin.time.Duration.Companion.milliseconds
class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragment {
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
private val videoScanThrottle = Throttler(150)
private val handler = Handler(Looper.getMainLooper())
private var isVideoGif = false
private var isInEdit = false
private var isFocused = false
private var wasPlayingBeforeEdit = false
private var maxSend: Long = 0
private lateinit var uri: Uri
private lateinit var controller: Controller
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()) {
val playbackPosition = player.truePlaybackPosition
if (playbackPosition >= 0) {
videoTimeLine.setActualPosition(playbackPosition.milliseconds.inWholeMicroseconds)
handler.postDelayed(this, 100)
}
}
}
}
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.")
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.mediasend_video_fragment, container, false)
}
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)!!
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)
hud.visible = !slide.isVideoGif
if (slide.isVideoGif) {
player.setPlayerCallback(object : PlayerCallback {
override fun onPlaying() {
controller.onPlayerReady()
}
override fun onStopped() = Unit
override fun onError(e: Exception) {
controller.onPlayerError()
}
})
player.hideControls()
player.loopForever()
player.mute()
player.disableAudioFocus()
player.play()
} else {
hud.setPlayClickListener {
player.play()
}
player.setOnClickListener {
player.pause()
hud.showPlayButton()
}
player.setPlayerCallback(object : PlayerCallback {
override fun onReady() {
controller.onPlayerReady()
}
override fun onPlaying() {
hud.fadePlayButton()
}
override fun onStopped() {
hud.showPlayButton()
}
override fun onError(e: Exception) {
controller.onPlayerError()
}
})
}
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)
}
}
} 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)
}
}
}
override fun onPositionDrag(position: Long) {
onSeek(position, false)
}
override fun onEndPositionDrag(position: Long) {
onSeek(position, true)
}
override fun onDestroyView() {
super.onDestroyView()
player.cleanup()
}
override fun onPause() {
super.onPause()
notifyHidden()
stopPositionUpdates()
}
override fun onResume() {
super.onResume()
startPositionUpdates()
if (isVideoGif) {
player.play()
}
}
private fun startPositionUpdates() {
stopPositionUpdates()
handler.post(updatePosition)
}
private fun stopPositionUpdates() {
handler.removeCallbacks(updatePosition)
}
override fun onHiddenChanged(hidden: Boolean) {
if (hidden) {
notifyHidden()
}
}
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() {
pausePlayback()
}
private fun pausePlayback() {
player.pause()
hud.showPlayButton()
}
private fun onEditVideoDuration(data: VideoTrimData, editingComplete: Boolean) {
if (editingComplete) {
isInEdit = false
videoScanThrottle.clear()
} else if (!isInEdit) {
isInEdit = true
}
wasPlayingBeforeEdit = player.isPlaying
if (wasPlayingBeforeEdit) {
hud.hidePlayButton()
}
videoScanThrottle.publish {
player.pause()
if (!editingComplete) {
player.removeClip(false)
}
if (!wasPlayingBeforeEdit) {
player.playbackPosition = if (editingComplete) data.startTimeUs / 1000 else data.endTimeUs / 1000
}
if (editingComplete) {
if (data.isDurationEdited) {
player.clip(data.startTimeUs, data.endTimeUs, wasPlayingBeforeEdit)
} else {
player.removeClip(wasPlayingBeforeEdit)
}
if (!wasPlayingBeforeEdit) {
hud.showPlayButton()
}
}
}
}
private fun onSeek(position: Long, dragComplete: Boolean) {
if (dragComplete) {
videoScanThrottle.clear()
}
videoScanThrottle.publish {
player.pause()
val milliseconds = position.microseconds.inWholeMilliseconds
player.playbackPosition = milliseconds
}
}
interface Controller {
fun onPlayerReady()
fun onPlayerError()
fun onTouchEventsNeeded(needed: Boolean)
}
companion object {
private val TAG = Log.tag(VideoEditorFragment::class.java)
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)
val fragment = VideoEditorFragment()
fragment.arguments = args
fragment.setUri(uri)
return fragment
}
}
}
@@ -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
@@ -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 {
/**
@@ -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
@@ -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
@@ -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
@@ -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()
}
@@ -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
@@ -1,48 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v2.videos
import android.os.Bundle
import kotlin.time.Duration
import kotlin.time.Duration.Companion.microseconds
/**
* This represents the editor state for the video trimmer.
*/
data class VideoTrimData(
val isDurationEdited: Boolean = false,
val totalInputDurationUs: Long = 0,
val startTimeUs: Long = 0,
val endTimeUs: Long = 0
) {
fun getDuration(): Duration = (endTimeUs - startTimeUs).microseconds
fun toBundle(): Bundle {
return Bundle().apply {
putByte(KEY_EDITED, (if (isDurationEdited) 1 else 0).toByte())
putLong(KEY_TOTAL, totalInputDurationUs)
putLong(KEY_START, startTimeUs)
putLong(KEY_END, endTimeUs)
}
}
companion object {
private const val KEY_EDITED = "EDITED"
private const val KEY_TOTAL = "TOTAL"
private const val KEY_START = "START"
private const val KEY_END = "END"
fun fromBundle(bundle: Bundle): VideoTrimData {
return VideoTrimData(
isDurationEdited = bundle.getByte(KEY_EDITED) == 1.toByte(),
totalInputDurationUs = bundle.getLong(KEY_TOTAL),
startTimeUs = bundle.getLong(KEY_START),
endTimeUs = bundle.getLong(KEY_END)
)
}
}
}
@@ -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()
@@ -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();
@@ -1,57 +0,0 @@
package org.thoughtcrime.securesms.scribbles
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
/**
* The play button overlay for controlling playback in the video editor.
*/
class VideoEditorPlayButtonLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr) {
private val playOverlay: View = inflate(this.context, R.layout.video_editor_hud, this).findViewById(R.id.play_overlay)
fun setPlayClickListener(listener: OnClickListener?) {
playOverlay.setOnClickListener(listener)
}
fun showPlayButton() {
playOverlay.visibility = VISIBLE
playOverlay.animate()
.setListener(null)
.alpha(1f)
.setInterpolator(createDefaultCubicBezierInterpolator())
.setDuration(500)
.start()
}
fun fadePlayButton() {
playOverlay.animate()
.setListener(object : Animator.AnimatorListener {
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
})
.alpha(0f)
.setInterpolator(createDefaultCubicBezierInterpolator())
.setDuration(200)
.start()
}
fun hidePlayButton() {
playOverlay.visibility = GONE
playOverlay.setAlpha(0f)
}
companion object {
@Suppress("unused")
private val TAG = tag(VideoEditorPlayButtonLayout::class.java)
}
}
@@ -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.
@@ -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()
@@ -1,27 +0,0 @@
package org.thoughtcrime.securesms.util
import android.view.animation.Animation
import android.view.animation.Interpolator
import androidx.core.view.animation.PathInterpolatorCompat
fun Animation.setListeners(
onAnimationStart: (animation: Animation?) -> Unit = { },
onAnimationEnd: (animation: Animation?) -> Unit = { },
onAnimationRepeat: (animation: Animation?) -> Unit = { }
) {
this.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation?) {
onAnimationStart(animation)
}
override fun onAnimationEnd(animation: Animation?) {
onAnimationEnd(animation)
}
override fun onAnimationRepeat(animation: Animation?) {
onAnimationRepeat(animation)
}
})
}
fun createDefaultCubicBezierInterpolator(): Interpolator = PathInterpolatorCompat.create(0.17f, 0.17f, 0f, 1f)
@@ -1,533 +0,0 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.video;
import android.content.Context;
import android.content.res.TypedArray;
import android.media.AudioAttributes;
import android.media.AudioFocusRequest;
import android.media.AudioManager;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.core.content.ContextCompat;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.analytics.AnalyticsListener;
import androidx.media3.exoplayer.source.ClippingMediaSource;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.source.LoadEventInfo;
import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.ui.AspectRatioFrameLayout;
import androidx.media3.ui.LegacyPlayerControlView;
import androidx.media3.ui.PlayerView;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.incrementalmac.InvalidMacException;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.mediapreview.MediaPreviewPlayerControlView;
import org.thoughtcrime.securesms.mms.VideoSlide;
import java.io.IOException;
import java.util.Objects;
@OptIn(markerClass = UnstableApi.class)
public class VideoPlayer extends FrameLayout {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(VideoPlayer.class);
private final PlayerView exoView;
private final View progressBar;
private final DefaultMediaSourceFactory mediaSourceFactory;
private ExoPlayer exoPlayer;
private LegacyPlayerControlView exoControls;
private Window window;
private PlayerStateCallback playerStateCallback;
private PlayerPositionDiscontinuityCallback playerPositionDiscontinuityCallback;
private PlayerCallback playerCallback;
private boolean clipped;
private long clippedStartUs;
private ExoPlayerListener exoPlayerListener;
private Player.Listener playerListener;
private AnalyticsListener analyticsListener;
private boolean muted;
private AudioFocusRequest audioFocusRequest;
private boolean requestAudioFocus = true;
public VideoPlayer(Context context) {
this(context, null);
}
public VideoPlayer(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public VideoPlayer(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.VideoPlayer);
int videPlayerLayout = typedArray.getResourceId(R.styleable.VideoPlayer_playerLayoutId, R.layout.video_player);
typedArray.recycle();
inflate(context, videPlayerLayout, this);
this.mediaSourceFactory = new DefaultMediaSourceFactory(context);
this.exoView = findViewById(R.id.video_view);
this.progressBar = findViewById(R.id.progress_bar);
this.exoControls = createPlayerControls(getContext());
final AudioManager audioManager = ContextCompat.getSystemService(context, AudioManager.class);
if (Build.VERSION.SDK_INT >= 26) {
audioFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(
new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.setOnAudioFocusChangeListener(focusChange -> {
})
.build();
} else {
audioFocusRequest = null;
}
this.exoPlayerListener = new ExoPlayerListener();
this.analyticsListener = new AnalyticsListener() {
@Override
public void onLoadError(EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) {
if (error instanceof InvalidMacException) {
Log.w(TAG, "Bad incremental mac!", error);
playerCallback.onError(error);
}
}
};
this.playerListener = new Player.Listener() {
@Override
public void onIsPlayingChanged(boolean isPlaying) {
if (!isPlaying && exoPlayer.getCurrentPosition() >= exoPlayer.getDuration()) {
exoPlayer.seekTo(0);
exoPlayer.setPlayWhenReady(false);
}
if (audioManager == null) {
return;
}
if (Build.VERSION.SDK_INT >= 26 && audioFocusRequest != null) {
if (isPlaying) {
if (requestAudioFocus) {
audioManager.requestAudioFocus(audioFocusRequest);
}
} else {
audioManager.abandonAudioFocusRequest(audioFocusRequest);
}
} else {
if (isPlaying) {
if (requestAudioFocus) {
audioManager.requestAudioFocus(
focusChange -> {
// Do nothing
},
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
);
}
} else {
audioManager.abandonAudioFocus(
focusChange -> {
// Do nothing
}
);
}
}
}
@Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
onPlaybackStateChanged(playWhenReady, exoPlayer.getPlaybackState());
}
@Override
public void onPlaybackStateChanged(int playbackState) {
onPlaybackStateChanged(exoPlayer.getPlayWhenReady(), playbackState);
}
private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) {
if (progressBar != null) {
if (playbackState == Player.STATE_BUFFERING) {
progressBar.setVisibility(View.VISIBLE);
} else {
progressBar.setVisibility(View.GONE);
}
}
if (playerCallback != null) {
switch (playbackState) {
case Player.STATE_READY:
playerCallback.onReady();
if (playWhenReady) {
playerCallback.onPlaying();
} else {
playerCallback.onStopped();
}
break;
case Player.STATE_ENDED:
playerCallback.onStopped();
break;
}
}
}
@Override
public void onPlayerError(@NonNull PlaybackException error) {
Log.w(TAG, "A player error occurred", error);
if (playerCallback != null) {
playerCallback.onError(error);
}
}
};
}
private LegacyPlayerControlView createPlayerControls(Context context) {
final LegacyPlayerControlView playerControlView = new LegacyPlayerControlView(context);
playerControlView.setShowTimeoutMs(-1);
playerControlView.setShowNextButton(false);
playerControlView.setShowPreviousButton(false);
return playerControlView;
}
private MediaItem mediaItem;
public void setVideoSource(@NonNull VideoSlide videoSource, boolean autoplay, String poolTag) {
setVideoSource(videoSource, autoplay, poolTag, 0, 0);
}
public void setVideoSource(@NonNull VideoSlide videoSource, boolean autoplay, String poolTag, long clipStartMs, long clipEndMs) {
if (exoPlayer == null) {
exoPlayer = AppDependencies.getExoPlayerPool().require(poolTag);
exoPlayer.addListener(exoPlayerListener);
exoPlayer.addListener(playerListener);
exoPlayer.addAnalyticsListener(analyticsListener);
exoView.setPlayer(exoPlayer);
exoControls.setPlayer(exoPlayer);
if (muted) {
mute();
}
}
mediaItem = MediaItem.fromUri(Objects.requireNonNull(videoSource.getUri())).buildUpon()
.setClippingConfiguration(getClippingConfiguration(clipStartMs, clipEndMs))
.build();
exoPlayer.setMediaItem(mediaItem);
exoPlayer.prepare();
exoPlayer.setPlayWhenReady(autoplay);
}
public void mute() {
this.muted = true;
if (exoPlayer != null) {
exoPlayer.setVolume(0f);
}
}
public void unmute() {
this.muted = false;
if (exoPlayer != null) {
exoPlayer.setVolume(1f);
}
}
public boolean hasAudioTrack() {
if (exoPlayer != null) {
Tracks tracks = exoPlayer.getCurrentTracks();
return tracks.containsType(C.TRACK_TYPE_AUDIO);
}
return false;
}
public boolean isInitialized() {
return exoPlayer != null;
}
public void setResizeMode(@AspectRatioFrameLayout.ResizeMode int resizeMode) {
exoView.setResizeMode(resizeMode);
}
public boolean isPlaying() {
if (this.exoPlayer != null) {
return this.exoPlayer.isPlaying();
} else {
return false;
}
}
public void pause() {
if (this.exoPlayer != null) {
this.exoPlayer.setPlayWhenReady(false);
}
}
public void hideControls() {
if (this.exoView != null) {
this.exoView.hideController();
}
}
public void setKeepContentOnPlayerReset(boolean keepContentOnPlayerReset) {
if (this.exoView != null) {
this.exoView.setKeepContentOnPlayerReset(keepContentOnPlayerReset);
}
}
@Override
public void setOnClickListener(@Nullable OnClickListener l) {
if (this.exoView != null) {
this.exoView.setClickable(false);
}
super.setOnClickListener(l);
}
public @Nullable LegacyPlayerControlView getControlView() {
return this.exoControls;
}
public void setControlView(MediaPreviewPlayerControlView controller) {
exoControls = controller;
exoControls.setPlayer(exoPlayer);
}
public void stop() {
if (this.exoPlayer != null) {
exoPlayer.stop();
exoPlayer.clearMediaItems();
}
}
public void cleanup() {
stop();
if (this.exoPlayer != null) {
exoView.setPlayer(null);
if (exoPlayer.equals(exoControls.getPlayer())) {
exoControls.setPlayer(null);
}
exoPlayer.removeListener(playerListener);
exoPlayer.removeListener(exoPlayerListener);
AppDependencies.getExoPlayerPool().pool(exoPlayer);
this.exoPlayer = null;
}
}
public void loopForever() {
if (this.exoPlayer != null) {
exoPlayer.setRepeatMode(Player.REPEAT_MODE_ONE);
}
}
public long getDuration() {
if (this.exoPlayer != null) {
return this.exoPlayer.getDuration();
}
return 0L;
}
public long getPlaybackPosition() {
if (this.exoPlayer != null) {
return this.exoPlayer.getCurrentPosition();
}
return 0L;
}
/**
* After calling {@link #setPlaybackPosition}, the underlying {@link Player} resets the current position to 0.
* We manually store the offset of where we clipped to, and add that here.
*
* @return the current playback position, rounded to the nearest millisecond
*/
public long getTruePlaybackPosition() {
if (this.exoPlayer != null) {
return this.exoPlayer.getCurrentPosition() + Math.round(clippedStartUs / 1000.0);
}
return -1L;
}
public void setPlaybackPosition(long positionMs) {
if (this.exoPlayer != null) {
this.exoPlayer.seekTo(positionMs);
}
}
public void clip(long fromUs, long toUs, boolean playWhenReady) {
if (this.exoPlayer != null && mediaItem != null) {
MediaSource mediaItemSource = mediaSourceFactory.createMediaSource(mediaItem);
ClippingMediaSource clippedSource = new ClippingMediaSource(mediaItemSource, fromUs, toUs);
exoPlayer.setMediaSource(clippedSource);
exoPlayer.prepare();
exoPlayer.setPlayWhenReady(playWhenReady);
clipped = true;
clippedStartUs = fromUs;
}
}
public void removeClip(boolean playWhenReady) {
if (exoPlayer != null && mediaItem != null) {
if (clipped) {
exoPlayer.setMediaItem(mediaItem);
exoPlayer.prepare();
clipped = false;
clippedStartUs = 0;
}
exoPlayer.setPlayWhenReady(playWhenReady);
}
}
public void setWindow(@Nullable Window window) {
this.window = window;
}
public void setPlayerStateCallbacks(@Nullable PlayerStateCallback playerStateCallback) {
this.playerStateCallback = playerStateCallback;
}
public void setPlayerCallback(PlayerCallback playerCallback) {
this.playerCallback = playerCallback;
}
public void setPlayerPositionDiscontinuityCallback(@NonNull PlayerPositionDiscontinuityCallback playerPositionDiscontinuityCallback) {
this.playerPositionDiscontinuityCallback = playerPositionDiscontinuityCallback;
}
/**
* Resumes a paused video, or restarts if at end of video.
*/
public void play() {
if (exoPlayer != null) {
exoPlayer.setPlayWhenReady(true);
if (exoPlayer.getCurrentPosition() >= exoPlayer.getDuration()) {
exoPlayer.seekTo(0);
}
}
}
public void disableAudioFocus() {
requestAudioFocus = false;
}
private @NonNull MediaItem.ClippingConfiguration getClippingConfiguration(long startMs, long endMs) {
return startMs != endMs ? new MediaItem.ClippingConfiguration.Builder()
.setStartPositionMs(startMs)
.setEndPositionMs(endMs)
.build()
: MediaItem.ClippingConfiguration.UNSET;
}
private class ExoPlayerListener implements Player.Listener {
@Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
onPlaybackStateChanged(playWhenReady, exoPlayer.getPlaybackState());
}
@Override
public void onPlaybackStateChanged(int playbackState) {
onPlaybackStateChanged(exoPlayer.getPlayWhenReady(), playbackState);
}
private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) {
switch (playbackState) {
case Player.STATE_IDLE:
case Player.STATE_BUFFERING:
case Player.STATE_ENDED:
if (window != null) {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
break;
case Player.STATE_READY:
if (window != null) {
if (playWhenReady) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
notifyPlayerReady();
break;
default:
break;
}
}
@Override
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition,
@NonNull Player.PositionInfo newPosition,
int reason)
{
if (playerPositionDiscontinuityCallback != null) {
playerPositionDiscontinuityCallback.onPositionDiscontinuity(VideoPlayer.this, reason);
}
}
private void notifyPlayerReady() {
if (playerStateCallback != null) playerStateCallback.onPlayerReady();
}
}
public interface PlayerStateCallback {
void onPlayerReady();
}
public interface PlayerPositionDiscontinuityCallback {
void onPositionDiscontinuity(@NonNull VideoPlayer player, int reason);
}
public interface PlayerCallback {
default void onReady() {}
void onPlaying();
void onStopped();
void onError(Exception e);
}
}
@@ -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()
}
@@ -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}.
*/
@@ -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<ExoPlayer>(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<T : ExoPlayer>(
private val maximumReservedPlayers: Int
) : AppForegroundObserver.Listener {
companion object {
private val TAG = Log.tag(ExoPlayerPool::class.java)
}
private val pool: MutableMap<T, PoolState> = mutableMapOf()
/**
* Try to get a player from the non-reserved pool.
*
* @return A player if one is available, otherwise null
*/
@MainThread
fun get(tag: String): T? {
return get(allowReserved = false, tag = tag)
}
/**
* Get a player, preferring reserved players.
*
* @return A non-null player instance. If one is not available, an exception is thrown.
* @throws IllegalStateException if no player is available.
*/
@MainThread
fun require(tag: String): T {
return checkNotNull(get(allowReserved = true, tag = tag)) { "Required exoPlayer could not be acquired for $tag! :: ${poolStats()}" }
}
/**
* Returns a player to the pool. If the player is not from the pool, an exception is thrown.
*
* @throws IllegalArgumentException if the player passed is not in the pool
*/
@MainThread
fun pool(exoPlayer: T) {
val poolState = pool[exoPlayer]
if (poolState != null) {
exoPlayer.stop()
exoPlayer.clearMediaItems()
pool[exoPlayer] = poolState.copy(available = true, tag = null)
} else {
throw IllegalArgumentException("Tried to return unknown ExoPlayer to pool :: ${poolStats()}")
}
}
@MainThread
private fun get(allowReserved: Boolean, tag: String): T? {
val player = findAvailablePlayer(allowReserved)
val toReturn = if (player == null && pool.size < getMaximumAllowed(allowReserved)) {
val newPlayer = createPlayer()
val poolState = createPoolStateForNewEntry(allowReserved, tag)
pool[newPlayer] = poolState
newPlayer
} else if (player != null) {
val poolState = pool[player]!!.copy(available = false, tag = tag)
pool[player] = poolState
player
} else {
Log.d(TAG, "Failed to get an ExoPlayer instance for tag: $tag :: ${poolStats()}")
null
}
return toReturn?.apply {
configureForVideoPlayback()
}
}
private fun getMaximumAllowed(allowReserved: Boolean): Int {
return if (allowReserved) getMaxSimultaneousPlayback() else getMaxSimultaneousPlayback() - maximumReservedPlayers
}
private fun createPoolStateForNewEntry(allowReserved: Boolean, tag: String?): PoolState {
return if (allowReserved && pool.none { (_, v) -> v.reserved }) {
PoolState(available = false, reserved = true, tag = tag)
} else {
PoolState(available = false, reserved = false, tag = tag)
}
}
private fun findAvailablePlayer(allowReserved: Boolean): T? {
return if (allowReserved) {
findFirstReservedAndAvailablePlayer() ?: findFirstUnreservedAndAvailablePlayer()
} else {
findFirstUnreservedAndAvailablePlayer()
}
}
private fun findFirstReservedAndAvailablePlayer(): T? {
return pool.filter { (_, v) -> v.reservedAndAvailable }.keys.firstOrNull()
}
private fun findFirstUnreservedAndAvailablePlayer(): T? {
return pool.filter { (_, v) -> v.unreservedAndAvailable }.keys.firstOrNull()
}
protected abstract fun createPlayer(): T
@MainThread
override fun onBackground() {
for ((player, state) in pool) {
if (!state.available && player.playWhenReady) {
Log.w(TAG, "Force-stopping orphaned playing player on background. Owner: ${state.tag}")
player.stop()
player.clearMediaItems()
}
}
val playersToRelease = pool.filter { (_, v) -> v.available }.keys
pool -= playersToRelease
playersToRelease.forEach { it.release() }
}
private fun poolStats(): String {
return getPoolStats().toString()
}
fun getPoolStats(): PoolStats {
val poolStats = PoolStats(
created = pool.size,
maxUnreserved = getMaxSimultaneousPlayback() - maximumReservedPlayers,
maxReserved = maximumReservedPlayers,
owners = emptyList()
)
return pool.values.fold(poolStats) { acc, state ->
Log.d(TAG, "$state")
acc.copy(
unreservedAndAvailable = acc.unreservedAndAvailable + if (state.unreservedAndAvailable) 1 else 0,
reservedAndAvailable = acc.reservedAndAvailable + if (state.reservedAndAvailable) 1 else 0,
unreserved = acc.unreserved + if (!state.reserved) 1 else 0,
reserved = acc.reserved + if (state.reserved) 1 else 0,
owners = if (!state.available) acc.owners + OwnershipInfo(state.tag!!, state.reserved) else acc.owners
)
}
}
@UnstableApi
object DataSourceTransferListener : TransferListener {
private val TAG = Log.tag(DataSourceTransferListener::class)
override fun onTransferInitializing(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {
Log.d(TAG, "onTransferInitializing() for ${source.uri}")
}
override fun onTransferStart(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {
Log.d(TAG, "onTransferStart() for ${source.uri}")
}
override fun onBytesTransferred(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean, bytesTransferred: Int) {}
override fun onTransferEnd(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {
Log.d(TAG, "onTransferEnd() for ${source.uri}")
}
}
protected abstract fun getMaxSimultaneousPlayback(): Int
data class PoolStats(
val created: Int = 0,
val maxUnreserved: Int = 0,
val maxReserved: Int = 0,
val unreservedAndAvailable: Int = 0,
val reservedAndAvailable: Int = 0,
val unreserved: Int = 0,
val reserved: Int = 0,
val owners: List<OwnershipInfo>
)
data class OwnershipInfo(
val tag: String,
val isReserved: Boolean
)
private data class PoolState(
val available: Boolean,
val reserved: Boolean,
val tag: String?
) {
val unreservedAndAvailable = available && !reserved
val reservedAndAvailable = available && reserved
}
}
@@ -1,460 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.video.videoconverter;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.util.AttributeSet;
import android.view.MotionEvent;
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);
private static final int ANIMATION_DURATION_MS = 100;
private static final float THUMB_RECT_CORNER_RADIUS = DimensionUnit.DP.toPixels(4);
private static final float ACTIVE_REGION_CORNER_RADIUS = DimensionUnit.DP.toPixels(8);
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint paintGrey = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint thumbTimeTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint thumbTimeBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Rect tempDrawRect = new Rect();
private final RectF timePillRect = new RectF();
private final Path activeRegionPath = new Path();
@Px private int left;
@Px private int right;
@Px private int cursor;
private Long minValue;
private Long maxValue;
private Long externalMinValue;
private Long externalMaxValue;
private float xDown;
private long downCursor;
private long downMin;
private long downMax;
private Thumb dragThumb;
private Thumb lastDragThumb;
private PositionDragListener playerDragListener;
private RangeDragListener editorOnRangeChangeListener;
@Px private int thumbSizePixels;
@Px private int thumbTouchRadius;
@ColorInt private int thumbColor;
private long thumbPosition;
@Px private int thumbHintTextSize;
@ColorInt private int thumbHintTextColor;
@ColorInt private int thumbHintBackgroundColor;
private long dragStartTimeMs;
private long dragEndTimeMs;
private long maximumSelectableRangeMicros;
public VideoThumbnailsRangeSelectorView(final Context context) {
super(context);
init(null);
}
public VideoThumbnailsRangeSelectorView(final Context context, final @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
public VideoThumbnailsRangeSelectorView(final Context context, final @Nullable AttributeSet attrs, final int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(attrs);
}
private void init(final @Nullable AttributeSet attrs) {
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView, 0, 0);
try {
thumbSizePixels = typedArray.getDimensionPixelSize(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbWidth, 1);
thumbColor = typedArray.getColor(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbColor, 0xffff0000);
thumbTouchRadius = typedArray.getDimensionPixelSize(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbTouchRadius, 50);
thumbHintTextSize = typedArray.getDimensionPixelSize(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextSize, 0);
thumbHintTextColor = typedArray.getColor(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextColor, 0xffff0000);
thumbHintBackgroundColor = typedArray.getColor(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbHintBackgroundColor, 0xff00ff00);
} finally {
typedArray.recycle();
}
}
paintGrey.setColor(0x7f000000);
paintGrey.setStyle(Paint.Style.FILL_AND_STROKE);
paintGrey.setStrokeWidth(1);
paint.setStrokeWidth(2);
thumbTimeTextPaint.setTextSize(thumbHintTextSize);
thumbTimeTextPaint.setColor(thumbHintTextColor);
thumbTimeBackgroundPaint.setStyle(Paint.Style.FILL_AND_STROKE);
thumbTimeBackgroundPaint.setColor(thumbHintBackgroundColor);
}
@Override
protected void afterDurationChange(long duration) {
maxValue = duration;
if (duration > 0) {
if (externalMaxValue != null) {
setMinMax(getMinValue(), externalMaxValue, Thumb.MAX);
externalMaxValue = null;
}
if (externalMinValue != null) {
setMinMax(externalMinValue, getMaxValue(), Thumb.MIN);
externalMinValue = null;
}
}
onRangeDrag(getMinValue(), getMaxValue(), duration, true);
invalidate();
}
public void registerPlayerDragListener(PositionDragListener playerDragListener) {
this.playerDragListener = playerDragListener;
}
public void registerEditorOnRangeChangeListener(RangeDragListener editorOnRangeChangeListener) {
this.editorOnRangeChangeListener = editorOnRangeChangeListener;
}
public void unregisterDragListener() {
this.playerDragListener = null;
}
public void setActualPosition(long position) {
if (this.thumbPosition != position) {
this.thumbPosition = position;
invalidate();
}
}
private void setThumbPosition(long position) {
if (this.thumbPosition != position) {
this.thumbPosition = Math.max(getMinValue(), Math.min(getMaxValue(), position));
invalidate();
}
}
@Override
protected void onDraw(final Canvas canvas) {
super.onDraw(canvas);
int drawableWidth = getDrawableWidth();
int drawableHeight = getDrawableHeight();
long duration = getDuration();
long min = getMinValue();
long max = getMaxValue();
long drawPosAt = thumbPosition;
left = duration != 0 ? (int) ((min * drawableWidth) / duration) : 0;
right = duration != 0 ? (int) ((max * drawableWidth) / duration) : drawableWidth;
cursor = duration != 0 ? (int) ((drawPosAt * drawableWidth) / duration) : drawableWidth;
canvas.save();
canvas.clipPath(clippingPath);
canvas.translate(getPaddingLeft(), getPaddingTop());
// draw greyed out areas
if (Build.VERSION.SDK_INT >= 26) {
activeRegionPath.reset();
timePillRect.set(left + 1, 0, right - 1, drawableHeight);
activeRegionPath.addRoundRect(timePillRect, ACTIVE_REGION_CORNER_RADIUS, ACTIVE_REGION_CORNER_RADIUS, Path.Direction.CW);
canvas.clipOutPath(activeRegionPath);
tempDrawRect.set(0, 0, drawableWidth, drawableHeight);
canvas.drawRect(tempDrawRect, paintGrey);
} else {
tempDrawRect.set(0, 0, left - 1, drawableHeight);
canvas.drawRect(tempDrawRect, paintGrey);
tempDrawRect.set(right + 1, 0, drawableWidth, drawableHeight);
canvas.drawRect(tempDrawRect, paintGrey);
}
canvas.restore();
canvas.translate(getPaddingLeft(), getPaddingTop());
int verticalThumbInset = drawableHeight / 4;
int halfThumbWidth = thumbSizePixels / 2;
// draw thumb rectangles
paint.setStyle(Paint.Style.FILL_AND_STROKE);
paint.setColor(thumbColor);
timePillRect.set(left - halfThumbWidth, verticalThumbInset, left + halfThumbWidth, drawableHeight - verticalThumbInset);
canvas.drawRoundRect(timePillRect, THUMB_RECT_CORNER_RADIUS, THUMB_RECT_CORNER_RADIUS, paint);
timePillRect.set(right - halfThumbWidth, verticalThumbInset, right + halfThumbWidth, drawableHeight - verticalThumbInset);
canvas.drawRoundRect(timePillRect, THUMB_RECT_CORNER_RADIUS, THUMB_RECT_CORNER_RADIUS, paint);
// draw time hint pill
if (thumbHintTextSize > 0) {
if (dragStartTimeMs > 0 && (dragThumb == Thumb.MIN || dragThumb == Thumb.MAX)) {
drawTimeHint(canvas, drawableWidth, dragThumb, false);
}
if (dragEndTimeMs > 0 && (lastDragThumb == Thumb.MIN || lastDragThumb == Thumb.MAX)) {
drawTimeHint(canvas, drawableWidth, lastDragThumb, true);
}
}
// draw current position marker
if (left <= cursor && cursor <= right && dragThumb != Thumb.MIN && dragThumb != Thumb.MAX) {
timePillRect.set(cursor - halfThumbWidth, 0, cursor + halfThumbWidth, drawableHeight);
paint.setStyle(Paint.Style.FILL_AND_STROKE);
paint.setColor(thumbColor);
canvas.drawRoundRect(timePillRect, THUMB_RECT_CORNER_RADIUS, THUMB_RECT_CORNER_RADIUS, paint);
}
}
private void drawTimeHint(Canvas canvas, int drawableWidth, Thumb dragThumb, boolean fadeOut) {
canvas.save();
long microsecondValue = dragThumb == Thumb.MIN ? getMinValue() : getMaxValue();
long seconds = TimeUnit.MICROSECONDS.toSeconds(microsecondValue);
String timeString = String.format(Locale.getDefault(), "%d:%02d", seconds / 60, seconds % 60);
float topBottomPadding = thumbHintTextSize * 0.5f;
float leftRightPadding = thumbHintTextSize * 0.75f;
thumbTimeTextPaint.getTextBounds(timeString, 0, timeString.length(), tempDrawRect);
timePillRect.set(tempDrawRect.left - leftRightPadding, tempDrawRect.top - topBottomPadding, tempDrawRect.right + leftRightPadding, tempDrawRect.bottom + topBottomPadding);
float halfPillWidth = timePillRect.width() / 2f;
float halfPillHeight = timePillRect.height() / 2f;
long animationTime = fadeOut ? ANIMATION_DURATION_MS - Math.min(ANIMATION_DURATION_MS, System.currentTimeMillis() - dragEndTimeMs)
: Math.min(ANIMATION_DURATION_MS, System.currentTimeMillis() - dragStartTimeMs);
float animationPosition = animationTime / (float) ANIMATION_DURATION_MS;
float scaleIn = 0.2f * animationPosition + 0.8f;
int alpha = (int) (255 * animationPosition);
if (dragThumb == Thumb.MAX) {
canvas.translate(Math.min(right, drawableWidth - halfPillWidth), 0);
} else {
canvas.translate(Math.max(left, halfPillWidth), 0);
}
float timePillOffset = timePillRect.height() * -1.5f;
canvas.translate(0, timePillOffset);
canvas.scale(scaleIn, scaleIn);
thumbTimeTextPaint.setAlpha(alpha);
thumbTimeBackgroundPaint.setAlpha(alpha);
canvas.translate(leftRightPadding - halfPillWidth, halfPillHeight);
canvas.drawRoundRect(timePillRect, halfPillHeight, halfPillHeight, thumbTimeBackgroundPaint);
canvas.drawText(timeString, 0, 0, thumbTimeTextPaint);
canvas.restore();
if (fadeOut && animationTime > 0 || !fadeOut && animationTime < ANIMATION_DURATION_MS) {
invalidate();
} else {
if (fadeOut) {
lastDragThumb = null;
}
}
}
public long getMinValue() {
return minValue == null ? 0 : minValue;
}
public long getMaxValue() {
return maxValue == null ? getDuration() : maxValue;
}
private boolean setMinValue(long minValue) {
if (this.minValue == null || this.minValue != minValue) {
return setMinMax(minValue, getMaxValue(), Thumb.MIN);
} else {
return false;
}
}
private boolean setMaxValue(long maxValue) {
if (this.maxValue == null || this.maxValue != maxValue) {
return setMinMax(getMinValue(), maxValue, Thumb.MAX);
} else {
return false;
}
}
private boolean setMinMax(long newMin, long newMax, Thumb thumb) {
final long currentMin = getMinValue();
final long currentMax = getMaxValue();
final long duration = getDuration();
final long minDiff = Math.max(MINIMUM_SELECTABLE_RANGE, pixelToDuration(thumbSizePixels * 2.5f));
final long maxDiff = maximumSelectableRangeMicros <= MINIMUM_SELECTABLE_RANGE ? 0 : Math.max(maximumSelectableRangeMicros, pixelToDuration(thumbSizePixels * 2.5f));
if (thumb == Thumb.MIN) {
newMin = clamp(newMin, 0, currentMax - minDiff);
if (maxDiff > 0) {
newMax = clamp(newMax, newMin + minDiff, Math.min(newMin + maxDiff, duration));
}
} else {
newMax = clamp(newMax, currentMin + minDiff, duration);
if (maxDiff > 0) {
newMin = clamp(newMin, Math.max(0, newMax - maxDiff), newMax - minDiff);
}
}
if (newMin != currentMin || newMax != currentMax) {
this.minValue = newMin;
this.maxValue = newMax;
invalidate();
return true;
}
return false;
}
private static long clamp(long value, long min, long max) {
return Math.min(Math.max(min, value), max);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int actionMasked = event.getActionMasked();
if (actionMasked == MotionEvent.ACTION_DOWN) {
xDown = event.getX();
downCursor = thumbPosition;
downMin = getMinValue();
downMax = getMaxValue();
dragThumb = closestThumb(event.getX());
dragStartTimeMs = System.currentTimeMillis();
invalidate();
return dragThumb != null;
}
if (actionMasked == MotionEvent.ACTION_MOVE) {
long delta = pixelToDuration(event.getX() - xDown);
boolean changed = switch (dragThumb) {
case POSITION -> {
setThumbPosition(pixelToDuration(event.getX()));
yield true;
}
case MIN -> setMinValue(downMin + delta);
case MAX -> setMaxValue(downMax + delta);
};
if (changed) {
if (dragThumb == Thumb.POSITION) {
onPositionDrag(thumbPosition);
} else {
onRangeDrag(getMinValue(), getMaxValue(), getDuration(), false);
}
}
return true;
}
if (actionMasked == MotionEvent.ACTION_UP) {
if (editorOnRangeChangeListener != null) {
if (dragThumb == Thumb.POSITION) {
onEndPositionDrag(thumbPosition);
} else {
onRangeDrag(getMinValue(), getMaxValue(), getDuration(), true);
}
lastDragThumb = dragThumb;
dragEndTimeMs = System.currentTimeMillis();
dragThumb = null;
invalidate();
}
return true;
}
if (actionMasked == MotionEvent.ACTION_CANCEL) {
dragThumb = null;
}
return true;
}
private Thumb closestThumb(@Px float x) {
float midPoint = (right + left) / 2f;
Thumb possibleThumb = x < midPoint ? Thumb.MIN : Thumb.MAX;
int possibleThumbX = x < midPoint ? left : right;
if (Math.abs(x - possibleThumbX) < thumbTouchRadius) {
return possibleThumb;
}
return Thumb.POSITION;
}
private long pixelToDuration(float pixel) {
return (long) (pixel / getDrawableWidth() * getDuration());
}
private int getDrawableWidth() {
return getWidth() - getPaddingLeft() - getPaddingRight();
}
private int getDrawableHeight() {
return getHeight() - getPaddingBottom() - getPaddingTop();
}
public void setRange(long minValue, long maxValue) {
if (getDuration() > 0) {
setMinMax(minValue, maxValue, Thumb.MIN);
} else {
externalMinValue = minValue;
externalMaxValue = maxValue;
}
}
public void setTimeLimit(int t, @NonNull TimeUnit timeUnit) {
maximumSelectableRangeMicros = timeUnit.toMicros(t);
}
private void onPositionDrag(long position) {
if (playerDragListener != null) {
playerDragListener.onPositionDrag(position);
}
}
private void onEndPositionDrag(long position) {
if (playerDragListener != null) {
playerDragListener.onEndPositionDrag(position);
}
}
private void onRangeDrag(long minValue, long maxValue, long duration, boolean end) {
if (editorOnRangeChangeListener != null) {
editorOnRangeChangeListener.onRangeDrag(minValue, maxValue, duration, end);
}
}
public enum Thumb {
MIN,
MAX,
POSITION
}
public interface PositionDragListener {
void onPositionDrag(long position);
void onEndPositionDrag(long position);
}
public interface RangeDragListener {
void onRangeDrag(long minValue, long maxValue, long duration, boolean start);
}
}
@@ -1,258 +0,0 @@
package org.thoughtcrime.securesms.video.videoconverter;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.net.Uri;
import android.os.AsyncTask;
import android.util.AttributeSet;
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 java.io.IOException;
import java.lang.ref.WeakReference;
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);
private static final int CORNER_RADIUS = (int) DimensionUnit.DP.toPixels(8);
protected Uri currentUri;
private MediaInput input;
private volatile ArrayList<Bitmap> thumbnails;
private AsyncTask<Void, Bitmap, Void> thumbnailsTask;
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF tempRect = new RectF();
private final Rect drawRect = new Rect();
private final Rect tempDrawRect = new Rect();
private long duration = 0;
protected final Path clippingPath = new Path();
public VideoThumbnailsView(final Context context) {
super(context);
}
public VideoThumbnailsView(final Context context, final @Nullable AttributeSet attrs) {
super(context, attrs);
}
public VideoThumbnailsView(final Context context, final @Nullable AttributeSet attrs, final int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
/**
* @return Whether or not the current URI was changed.
*/
public boolean setInput(@NonNull Uri uri) throws IOException {
if (uri.equals(this.currentUri)) {
return false;
}
this.currentUri = uri;
this.input = DecryptableUriMediaInput.createForUri(getContext(), uri);
this.thumbnails = null;
if (thumbnailsTask != null) {
thumbnailsTask.cancel(true);
thumbnailsTask = null;
}
invalidate();
return true;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
thumbnails = null;
if (thumbnailsTask != null) {
thumbnailsTask.cancel(true);
thumbnailsTask = null;
}
if (input != null) {
try {
input.close();
} catch (IOException e) {
Log.w(TAG, e);
}
}
}
@Override
protected void onDraw(final Canvas canvas) {
super.onDraw(canvas);
if (input == null) {
return;
}
final int left = getPaddingLeft();
final int top = getPaddingTop();
final int right = getWidth() - getPaddingRight();
final int bottom = getHeight() - getPaddingBottom();
clippingPath.reset();
clippingPath.addRoundRect(left, top, right, bottom, CORNER_RADIUS, CORNER_RADIUS, Path.Direction.CW);
tempDrawRect.set(left, top, right, bottom);
if (!drawRect.equals(tempDrawRect)) {
drawRect.set(tempDrawRect);
thumbnails = null;
if (thumbnailsTask != null) {
thumbnailsTask.cancel(true);
thumbnailsTask = null;
}
}
if (thumbnails == null) {
if (thumbnailsTask == null) {
final int thumbnailCount = drawRect.width() / drawRect.height();
final float thumbnailWidth = (float) drawRect.width() / thumbnailCount;
final float thumbnailHeight = drawRect.height();
thumbnails = new ArrayList<>(thumbnailCount);
thumbnailsTask = new ThumbnailsTask(this, input, thumbnailWidth, thumbnailHeight, thumbnailCount);
thumbnailsTask.execute();
}
} else {
final int thumbnailCount = drawRect.width() / drawRect.height();
final float thumbnailWidth = (float) drawRect.width() / thumbnailCount;
final float thumbnailHeight = drawRect.height();
tempRect.top = drawRect.top;
tempRect.bottom = drawRect.bottom;
canvas.save();
canvas.clipPath(clippingPath);
for (int i = 0; i < thumbnails.size(); i++) {
tempRect.left = drawRect.left + i * thumbnailWidth;
tempRect.right = tempRect.left + thumbnailWidth;
final Bitmap thumbnailBitmap = thumbnails.get(i);
if (thumbnailBitmap != null) {
canvas.save();
canvas.rotate(180, tempRect.centerX(), tempRect.centerY());
tempDrawRect.set(0, 0, thumbnailBitmap.getWidth(), thumbnailBitmap.getHeight());
if (tempDrawRect.width() * thumbnailHeight > tempDrawRect.height() * thumbnailWidth) {
float w = tempDrawRect.height() * thumbnailWidth / thumbnailHeight;
tempDrawRect.left = tempDrawRect.centerX() - (int) (w / 2);
tempDrawRect.right = tempDrawRect.left + (int) w;
} else {
float h = tempDrawRect.width() * thumbnailHeight / thumbnailWidth;
tempDrawRect.top = tempDrawRect.centerY() - (int) (h / 2);
tempDrawRect.bottom = tempDrawRect.top + (int) h;
}
canvas.drawBitmap(thumbnailBitmap, tempDrawRect, tempRect, paint);
canvas.restore();
}
}
canvas.restore();
}
}
private void setDuration(long duration) {
if (this.duration != duration) {
this.duration = duration;
afterDurationChange(duration);
}
}
abstract void afterDurationChange(long duration);
public long getDuration() {
return duration;
}
private static class ThumbnailsTask extends AsyncTask<Void, Bitmap, Void> {
final WeakReference<VideoThumbnailsView> viewReference;
final MediaInput input;
final float thumbnailWidth;
final float thumbnailHeight;
final int thumbnailCount;
long duration;
ThumbnailsTask(final @NonNull VideoThumbnailsView view, final @NonNull MediaInput input, final float thumbnailWidth, final float thumbnailHeight, final int thumbnailCount) {
this.viewReference = new WeakReference<>(view);
this.input = input;
this.thumbnailWidth = thumbnailWidth;
this.thumbnailHeight = thumbnailHeight;
this.thumbnailCount = thumbnailCount;
}
@Override
protected Void doInBackground(Void... params) {
Log.i(TAG, "generate " + thumbnailCount + " thumbnails " + thumbnailWidth + "x" + thumbnailHeight);
VideoThumbnailsExtractor.extractThumbnails(input, thumbnailCount, (int) thumbnailHeight, new VideoThumbnailsExtractor.Callback() {
@Override
public void durationKnown(long duration) {
ThumbnailsTask.this.duration = duration;
}
@Override
public boolean publishProgress(int index, Bitmap thumbnail) {
boolean notCanceled = !isCancelled();
if (notCanceled) {
ThumbnailsTask.this.publishProgress(thumbnail);
}
return notCanceled;
}
@Override
public void failed() {
Log.w(TAG, "Thumbnail extraction failed");
}
});
return null;
}
@Override
protected void onProgressUpdate(Bitmap... values) {
if (isCancelled()) {
return;
}
VideoThumbnailsView view = viewReference.get();
List<Bitmap> thumbnails = view != null ? view.thumbnails : null;
if (thumbnails != null) {
thumbnails.addAll(Arrays.asList(values));
view.invalidate();
}
}
@Override
protected void onPostExecute(Void result) {
VideoThumbnailsView view = viewReference.get();
List<Bitmap> thumbnails = view != null ? view.thumbnails : null;
if (view != null) {
view.setDuration(ThumbnailsTask.this.duration);
view.invalidate();
Log.i(TAG, "onPostExecute, we have " + (thumbnails != null ? thumbnails.size() : "null") + " thumbs");
}
}
}
}