mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-06 13:31:18 +01:00
Move the VideoEditorFragment to the media-send feature module.
This commit is contained in:
committed by
Greyson Parrelli
parent
987f92245d
commit
83cb48d119
@@ -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
|
||||
|
||||
+12
-10
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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.
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-3
@@ -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()
|
||||
}
|
||||
|
||||
+39
-21
@@ -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()
|
||||
|
||||
+5
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+4
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-460
@@ -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);
|
||||
}
|
||||
}
|
||||
-258
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user