diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index 6e81b0529f..e3dfcb192a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -1324,7 +1324,7 @@ class AttachmentTable( useTemplateUpload = possibleTemplate.uploadTimestamp > attachment.uploadTimestamp && possibleTemplate.transferState == TRANSFER_PROGRESS_DONE && possibleTemplate.transformProperties?.shouldSkipTransform() == true && possibleTemplate.remoteDigest != null && - attachment.transformProperties?.videoEdited == false && possibleTemplate.transformProperties?.sentMediaQuality == attachment.transformProperties?.sentMediaQuality + attachment.transformProperties?.videoEdited == false && possibleTemplate.transformProperties.sentMediaQuality == attachment.transformProperties.sentMediaQuality if (useTemplateUpload) { Log.i(TAG, "Found a duplicate attachment upon insertion. Using it as a template.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index 5bc9c6f3c0..f11124f9a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -40,10 +40,10 @@ import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MemoryFileDescriptor.MemoryFileException; import org.thoughtcrime.securesms.video.InMemoryTranscoder; import org.thoughtcrime.securesms.video.StreamingTranscoder; -import org.thoughtcrime.securesms.video.exceptions.VideoPostProcessingException; -import org.thoughtcrime.securesms.video.interfaces.TranscoderCancelationSignal; import org.thoughtcrime.securesms.video.TranscoderOptions; +import org.thoughtcrime.securesms.video.exceptions.VideoPostProcessingException; import org.thoughtcrime.securesms.video.exceptions.VideoSourceException; +import org.thoughtcrime.securesms.video.interfaces.TranscoderCancelationSignal; import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor; import org.thoughtcrime.securesms.video.videoconverter.exceptions.EncodingException; @@ -258,7 +258,7 @@ public final class AttachmentCompressionJob extends BaseJob { } if (FeatureFlags.useStreamingVideoMuxer()) { - StreamingTranscoder transcoder = new StreamingTranscoder(dataSource, options, constraints.getCompressedVideoMaxSize(context), FeatureFlags.allowAudioRemuxing()); + StreamingTranscoder transcoder = new StreamingTranscoder(dataSource, options, constraints.getVideoTranscodingSettings(), constraints.getCompressedVideoMaxSize(context), FeatureFlags.allowAudioRemuxing()); if (transcoder.isTranscodeRequired()) { Log.i(TAG, "Compressing with streaming muxer"); @@ -327,7 +327,7 @@ public final class AttachmentCompressionJob extends BaseJob { Log.i(TAG, "Transcode was not required"); } } else { - try (InMemoryTranscoder transcoder = new InMemoryTranscoder(context, dataSource, options, constraints.getCompressedVideoMaxSize(context))) { + try (InMemoryTranscoder transcoder = new InMemoryTranscoder(context, dataSource, options, constraints.getVideoTranscodingSettings(), constraints.getCompressedVideoMaxSize(context))) { if (transcoder.isTranscodeRequired()) { Log.i(TAG, "Compressing with android in-memory muxer"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java index cfc74a6375..a7862cc785 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java @@ -244,7 +244,7 @@ public class LinkPreviewRepository { bitmap, maxDimension, mediaConfig.getMaxImageFileSize(), - mediaConfig.getQualitySetting() + mediaConfig.getImageQualitySetting() ); if (result != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/FileMediaInput.kt b/app/src/main/java/org/thoughtcrime/securesms/media/FileMediaInput.kt index a35039c808..84b23f1b9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/FileMediaInput.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/FileMediaInput.kt @@ -20,5 +20,9 @@ class FileMediaInput(private val file: File) : MediaInput { return extractor } + override fun hasSameInput(other: MediaInput): Boolean { + return other is FileMediaInput && other.file == this.file + } + override fun close() {} } diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/UriMediaInput.kt b/app/src/main/java/org/thoughtcrime/securesms/media/UriMediaInput.kt index 83c01decf4..b94cd58fe5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/UriMediaInput.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/UriMediaInput.kt @@ -21,5 +21,9 @@ class UriMediaInput(private val context: Context, private val uri: Uri) : MediaI return extractor } - override fun close() {} + override fun hasSameInput(other: MediaInput): Boolean { + return other is UriMediaInput && other.uri == this.uri + } + + override fun close() = Unit } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java index 4addaba88b..d0b3417df7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java @@ -55,11 +55,6 @@ public class MediaSendGifFragment extends Fragment implements MediaSendPageFragm return uri; } - @Override - public @Nullable View getPlaybackControls() { - return null; - } - @Override public @Nullable Object saveState() { return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java index 4d3f3d8d82..f08e3b705d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java @@ -15,8 +15,6 @@ public interface MediaSendPageFragment { void setUri(@NonNull Uri uri); - @Nullable View getPlaybackControls(); - @Nullable Object saveState(); void restoreState(@NonNull Object state); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.java deleted file mode 100644 index 7b2c64601f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.java +++ /dev/null @@ -1,413 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.fragment.app.Fragment; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.mms.MediaConstraints; -import org.thoughtcrime.securesms.mms.VideoSlide; -import org.thoughtcrime.securesms.scribbles.VideoEditorHud; -import org.thoughtcrime.securesms.util.Throttler; -import org.thoughtcrime.securesms.video.VideoBitRateCalculator; -import org.thoughtcrime.securesms.video.VideoPlayer; - -import java.io.IOException; -import java.util.concurrent.TimeUnit; - -public class VideoEditorFragment extends Fragment implements VideoEditorHud.EventListener, - MediaSendPageFragment { - - private static final String TAG = Log.tag(VideoEditorFragment.class); - - private static final String KEY_URI = "uri"; - private static final String KEY_MAX_OUTPUT = "max_output_size"; - private static final String KEY_MAX_SEND = "max_send_size"; - private static final String KEY_IS_VIDEO_GIF = "is_video_gif"; - private static final String KEY_MAX_DURATION = "max_duration"; - - private final Throttler videoScanThrottle = new Throttler(150); - private final Handler handler = new Handler(Looper.getMainLooper()); - - private Controller controller; - private Data data = new Data(); - private Uri uri; - private boolean isVideoGif; - private VideoPlayer player; - @Nullable private VideoEditorHud hud; - private Runnable updatePosition; - private boolean isInEdit; - private boolean wasPlayingBeforeEdit; - private long maxVideoDurationUs; - - public static VideoEditorFragment newInstance(@NonNull Uri uri, long maxCompressedVideoSize, long maxAttachmentSize, boolean isVideoGif, long maxVideoDuration) { - Bundle args = new Bundle(); - args.putParcelable(KEY_URI, uri); - args.putLong(KEY_MAX_OUTPUT, maxCompressedVideoSize); - args.putLong(KEY_MAX_SEND, maxAttachmentSize); - args.putBoolean(KEY_IS_VIDEO_GIF, isVideoGif); - args.putLong(KEY_MAX_DURATION, maxVideoDuration); - - VideoEditorFragment fragment = new VideoEditorFragment(); - fragment.setArguments(args); - fragment.setUri(uri); - return fragment; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (getActivity() instanceof Controller) { - controller = (Controller) getActivity(); - } else if (getParentFragment() instanceof Controller) { - controller = (Controller) getParentFragment(); - } else { - throw new IllegalStateException("Parent must implement Controller interface."); - } - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.mediasend_video_fragment, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - player = view.findViewById(R.id.video_player); - - uri = requireArguments().getParcelable(KEY_URI); - isVideoGif = requireArguments().getBoolean(KEY_IS_VIDEO_GIF); - maxVideoDurationUs = TimeUnit.MILLISECONDS.toMicros(requireArguments().getLong(KEY_MAX_DURATION)); - - long maxOutput = requireArguments().getLong(KEY_MAX_OUTPUT); - long maxSend = requireArguments().getLong(KEY_MAX_SEND); - VideoSlide slide = new VideoSlide(requireContext(), uri, 0, isVideoGif); - boolean autoplay = isVideoGif; - - player.setWindow(requireActivity().getWindow()); - player.setVideoSource(slide, autoplay, TAG); - - if (slide.isVideoGif()) { - player.setPlayerCallback(new VideoPlayer.PlayerCallback() { - @Override - public void onPlaying() { - controller.onPlayerReady(); - } - - @Override - public void onStopped() { - // Do nothing. - } - - @Override - public void onError() { - controller.onPlayerError(); - } - }); - player.hideControls(); - player.loopForever(); - } else if (MediaConstraints.isVideoTranscodeAvailable()) { - hud = view.findViewById(R.id.video_editor_hud); - hud.setEventListener(this); - clampToMaxVideoDuration(data, true); - updateHud(data); - if (data.durationEdited) { - player.clip(data.startTimeUs, data.endTimeUs, autoplay); - } - try { - hud.setVideoSource(slide, new VideoBitRateCalculator(maxOutput), maxOutput); - hud.setVisibility(View.VISIBLE); - startPositionUpdates(); - } catch (IOException e) { - Log.w(TAG, e); - } - - player.setOnClickListener(v -> { - player.pause(); - hud.showPlayButton(); - }); - - player.setPlayerCallback(new VideoPlayer.PlayerCallback() { - - @Override - public void onReady() { - controller.onPlayerReady(); - } - - @Override - public void onPlaying() { - hud.fadePlayButton(); - } - - @Override - public void onStopped() { - hud.showPlayButton(); - } - - @Override - public void onError() { - controller.onPlayerError(); - } - }); - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - if (player != null) { - player.cleanup(); - } - } - - @Override - public void onPause() { - super.onPause(); - notifyHidden(); - - stopPositionUpdates(); - } - - @Override - public void onResume() { - super.onResume(); - startPositionUpdates(); - - if (player != null && isVideoGif) { - player.play(); - } - } - - private void startPositionUpdates() { - if (hud != null && Build.VERSION.SDK_INT >= 23) { - stopPositionUpdates(); - updatePosition = new Runnable() { - @Override - public void run() { - hud.setPosition(player.getPlaybackPositionUs()); - handler.postDelayed(this, 100); - } - }; - handler.post(updatePosition); - } - } - - private void stopPositionUpdates() { - handler.removeCallbacks(updatePosition); - } - - @Override - public void onHiddenChanged(boolean hidden) { - if (hidden) { - notifyHidden(); - } - } - - @Override - public void setUri(@NonNull Uri uri) { - this.uri = uri; - } - - @Override - public @NonNull Uri getUri() { - return uri; - } - - @Override - public @Nullable View getPlaybackControls() { - if (hud != null && hud.getVisibility() == View.VISIBLE) return null; - else if (isVideoGif) return null; - else if (player != null) return player.getControlView(); - else return null; - } - - @Override - public @Nullable Object saveState() { - return data; - } - - @Override - public void restoreState(@NonNull Object state) { - if (state instanceof Data) { - data = (Data) state; - if (Build.VERSION.SDK_INT >= 23) { - updateHud(data); - } - } else { - Log.w(TAG, "Received a bad saved state. Received class: " + state.getClass().getName()); - } - } - - @RequiresApi(api = 23) - private void updateHud(Data data) { - if (hud != null && data.totalDurationUs > 0 && data.durationEdited) { - hud.setDurationRange(data.totalDurationUs, data.startTimeUs, data.endTimeUs); - } - } - - @Override - public void notifyHidden() { - pausePlayback(); - } - - public void pausePlayback() { - if (player != null) { - player.pause(); - if (hud != null) { - hud.showPlayButton(); - } - } - } - - @Override - public void onEditVideoDuration(long totalDurationUs, long startTimeUs, long endTimeUs, boolean fromEdited, boolean editingComplete) { - controller.onTouchEventsNeeded(!editingComplete); - - if (hud != null) { - hud.hidePlayButton(); - } - - final long clampedStartTime = Math.max(startTimeUs, 0); - - boolean wasEdited = data.durationEdited; - boolean durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs; - boolean endMoved = data.endTimeUs != endTimeUs; - - data.durationEdited = durationEdited; - data.totalDurationUs = totalDurationUs; - data.startTimeUs = clampedStartTime; - data.endTimeUs = endTimeUs; - - clampToMaxVideoDuration(data, !endMoved); - - if (editingComplete) { - isInEdit = false; - videoScanThrottle.clear(); - } else if (!isInEdit) { - isInEdit = true; - wasPlayingBeforeEdit = player.isPlaying(); - } - - videoScanThrottle.publish(() -> { - player.pause(); - if (!editingComplete) { - player.removeClip(false); - } - player.setPlaybackPosition(fromEdited || editingComplete ? clampedStartTime / 1000 : endTimeUs / 1000); - if (editingComplete) { - if (durationEdited) { - player.clip(clampedStartTime, endTimeUs, wasPlayingBeforeEdit); - } else { - player.removeClip(wasPlayingBeforeEdit); - } - - if (!wasPlayingBeforeEdit) { - hud.showPlayButton(); - } - } - }); - - if (!wasEdited && durationEdited) { - controller.onVideoBeginEdit(uri); - } - - if (editingComplete) { - controller.onVideoEndEdit(uri); - } - } - - @Override - public void onPlay() { - player.play(); - } - - @Override - public void onSeek(long position, boolean dragComplete) { - if (dragComplete) { - videoScanThrottle.clear(); - } - - videoScanThrottle.publish(() -> { - player.pause(); - player.setPlaybackPosition(position); - }); - } - - private void clampToMaxVideoDuration(@NonNull Data data, boolean clampEnd) { - if (!MediaConstraints.isVideoTranscodeAvailable()) { - return; - } - - if ((data.endTimeUs - data.startTimeUs) <= maxVideoDurationUs) { - return; - } - - data.durationEdited = true; - - if (clampEnd) { - data.endTimeUs = data.startTimeUs + maxVideoDurationUs; - } else { - data.startTimeUs = data.endTimeUs - maxVideoDurationUs; - } - - updateHud(data); - } - - public static class Data { - boolean durationEdited; - long totalDurationUs; - long startTimeUs; - long endTimeUs; - - public boolean isDurationEdited() { - return durationEdited; - } - - public @NonNull Bundle getBundle() { - Bundle bundle = new Bundle(); - bundle.putByte("EDITED", (byte) (durationEdited ? 1 : 0)); - bundle.putLong("TOTAL", totalDurationUs); - bundle.putLong("START", startTimeUs); - bundle.putLong("END", endTimeUs); - - return bundle; - } - - public static @NonNull Data fromBundle(@NonNull Bundle bundle) { - Data data = new Data(); - data.durationEdited = bundle.getByte("EDITED") == (byte) 1; - data.totalDurationUs = bundle.getLong("TOTAL"); - data.startTimeUs = bundle.getLong("START"); - data.endTimeUs = bundle.getLong("END"); - - return data; - } - } - - public interface Controller { - - void onPlayerReady(); - - void onPlayerError(); - - void onTouchEventsNeeded(boolean needed); - - void onVideoBeginEdit(@NonNull Uri uri); - - void onVideoEndEdit(@NonNull Uri uri); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt new file mode 100644 index 0000000000..8016fd76dd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt @@ -0,0 +1,363 @@ +package org.thoughtcrime.securesms.mediasend + +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.RequiresApi +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel +import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.mms.VideoSlide +import org.thoughtcrime.securesms.scribbles.VideoEditorPlayButtonLayout +import org.thoughtcrime.securesms.util.Throttler +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.OnRangeChangeListener +import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView.Thumb +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.math.max + +class VideoEditorFragment : Fragment(), OnRangeChangeListener, MediaSendPageFragment { + private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() }) + private val videoScanThrottle = Throttler(150) + private val handler = Handler(Looper.getMainLooper()) + + private var data = VideoTrimData() + private var canEdit = false + private var isVideoGif = false + private var isInEdit = false + private var isFocused = false + private var wasPlayingBeforeEdit = false + private var maxVideoDurationUs: Long = 0 + 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 playbackPositionUs = player.playbackPositionUs + if (playbackPositionUs >= 0) { + videoTimeLine.setActualPosition(playbackPositionUs) + 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) + maxVideoDurationUs = TimeUnit.MILLISECONDS.toMicros(requireArguments().getLong(KEY_MAX_DURATION)) + + val state = sharedViewModel.state.value!! + val slide = VideoSlide(requireContext(), uri, 0, isVideoGif) + player.setWindow(requireActivity().window) + player.setVideoSource(slide, isVideoGif, TAG) + + if (slide.isVideoGif) { + player.setPlayerCallback(object : PlayerCallback { + override fun onPlaying() { + controller.onPlayerReady() + } + + override fun onStopped() = Unit + + override fun onError() { + controller.onPlayerError() + } + }) + player.hideControls() + player.loopForever() + } else if (MediaConstraints.isVideoTranscodeAvailable()) { + hud.setPlayClickListener { + player.play() + } + bindVideoTimeline(state) + 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() { + controller.onPlayerError() + } + }) + } + + sharedViewModel.state.observe(viewLifecycleOwner) { state -> + val focusedMedia = state.focusedMedia + val currentlyFocused = focusedMedia?.uri != null && focusedMedia.uri == uri + if (MediaConstraints.isVideoTranscodeAvailable() && canEdit && !isFocused && currentlyFocused) { + bindVideoTimeline(state) + } + + if (!currentlyFocused) { + stopPositionUpdates() + } + isFocused = currentlyFocused + } + } + + @RequiresApi(23) + private fun bindVideoTimeline(state: MediaSelectionState) { + val uri = state.focusedMedia?.uri ?: return + if (uri != this.uri) { + return + } + + val autoplay = isVideoGif + val slide = VideoSlide(requireContext(), uri, 0, autoplay) + + if (data.isDurationEdited) { + player.clip(data.startTimeUs, data.endTimeUs, autoplay) + } + if (slide.hasVideo()) { + canEdit = true + try { + videoTimeLine.setOnRangeChangeListener(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) + } + + @RequiresApi(23) + override fun onRangeDrag(minValueUs: Long, maxValueUs: Long, durationUs: Long, thumb: Thumb) { + onEditVideoDuration(durationUs, minValueUs, maxValueUs, thumb == Thumb.MIN, false) + } + + @RequiresApi(23) + override fun onRangeDragEnd(minValueUs: Long, maxValueUs: Long, durationUs: Long, thumb: Thumb) { + onEditVideoDuration(durationUs, minValueUs, maxValueUs, thumb == Thumb.MIN, 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() { + if (Build.VERSION.SDK_INT >= 23) { + 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 { + return data + } + + override fun restoreState(state: Any) { + if (state is VideoTrimData) { + data = state + } else { + Log.w(TAG, "Received a bad saved state. Received class: " + state.javaClass.name) + } + } + + override fun notifyHidden() { + pausePlayback() + } + + private fun pausePlayback() { + player.pause() + hud.showPlayButton() + } + + @RequiresApi(23) + private fun onEditVideoDuration(totalDurationUs: Long, startTimeUs: Long, endTimeUs: Long, fromEdited: Boolean, editingComplete: Boolean) { + controller.onTouchEventsNeeded(!editingComplete) + + hud.hidePlayButton() + + val clampedStartTime = max(startTimeUs.toDouble(), 0.0).toLong() + + val wasEdited = data.isDurationEdited + val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs + val endMoved = data.endTimeUs != endTimeUs + + val updatedData = MediaSelectionViewModel.clampToMaxClipDuration(VideoTrimData(durationEdited, totalDurationUs, clampedStartTime, endTimeUs), maxVideoDurationUs, !endMoved) + + if (editingComplete) { + isInEdit = false + videoScanThrottle.clear() + } else if (!isInEdit) { + isInEdit = true + wasPlayingBeforeEdit = player.isPlaying + } + + videoScanThrottle.publish { + player.pause() + if (!editingComplete) { + player.removeClip(false) + } + player.playbackPosition = if (fromEdited || editingComplete) clampedStartTime / 1000 else endTimeUs / 1000 + if (editingComplete) { + if (durationEdited) { + player.clip(clampedStartTime, endTimeUs, wasPlayingBeforeEdit) + } else { + player.removeClip(wasPlayingBeforeEdit) + } + + if (!wasPlayingBeforeEdit) { + hud.showPlayButton() + } + } + } + + if (!wasEdited && durationEdited) { + controller.onVideoBeginEdit(uri) + } + + if (editingComplete) { + controller.onVideoEndEdit(uri) + } + + uri.let { + sharedViewModel.setEditorState(it, updatedData) + } + } + + private fun onSeek(position: Long, dragComplete: Boolean) { + if (dragComplete) { + videoScanThrottle.clear() + } + + videoScanThrottle.publish { + player.pause() + player.playbackPosition = position + } + } + + interface Controller { + fun onPlayerReady() + + fun onPlayerError() + + fun onTouchEventsNeeded(needed: Boolean) + + fun onVideoBeginEdit(uri: Uri) + + fun onVideoEndEdit(uri: Uri) + } + + 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" + private const val KEY_MAX_DURATION = "max_duration" + + fun newInstance(uri: Uri, maxAttachmentSize: Long, isVideoGif: Boolean, maxVideoDuration: Long): VideoEditorFragment { + val args = Bundle() + args.putParcelable(KEY_URI, uri) + args.putLong(KEY_MAX_SEND, maxAttachmentSize) + args.putBoolean(KEY_IS_VIDEO_GIF, isVideoGif) + args.putLong(KEY_MAX_DURATION, maxVideoDuration) + + val fragment = VideoEditorFragment() + fragment.arguments = args + fragment.setUri(uri) + return fragment + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.java deleted file mode 100644 index 4bb9cc1307..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; - -import org.thoughtcrime.securesms.database.AttachmentTable; -import org.thoughtcrime.securesms.mms.SentMediaQuality; - -import java.util.Optional; - - -public final class VideoTrimTransform implements MediaTransform { - - private final VideoEditorFragment.Data data; - - public VideoTrimTransform(@NonNull VideoEditorFragment.Data data) { - this.data = data; - } - - @WorkerThread - @Override - public @NonNull Media transform(@NonNull Context context, @NonNull Media media) { - return new Media(media.getUri(), - media.getMimeType(), - media.getDate(), - media.getWidth(), - media.getHeight(), - media.getSize(), - media.getDuration(), - media.isBorderless(), - media.isVideoGif(), - media.getBucketId(), - media.getCaption(), - Optional.of(new AttachmentTable.TransformProperties(false, data.durationEdited, data.startTimeUs, data.endTimeUs, SentMediaQuality.STANDARD.getCode(), false))); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt new file mode 100644 index 0000000000..77aa092f74 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.mediasend + +import android.content.Context +import androidx.annotation.WorkerThread +import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties +import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData +import org.thoughtcrime.securesms.mms.SentMediaQuality +import java.util.Optional + +class VideoTrimTransform(private val data: VideoTrimData) : MediaTransform { + @WorkerThread + override fun transform(context: Context, media: Media): Media { + return Media( + media.uri, + media.mimeType, + media.date, + media.width, + media.height, + media.size, + media.duration, + media.isBorderless, + media.isVideoGif, + media.bucketId, + media.caption, + Optional.of(TransformProperties(false, data.isDurationEdited, data.startTimeUs, data.endTimeUs, SentMediaQuality.STANDARD.code, false)) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaAnimations.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaAnimations.kt index c796344847..0cbed31ba8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaAnimations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaAnimations.kt @@ -1,12 +1,15 @@ package org.thoughtcrime.securesms.mediasend.v2 import android.view.animation.Interpolator -import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import android.view.animation.LinearInterpolator +import org.thoughtcrime.securesms.util.createDefaultCubicBezierInterpolator object MediaAnimations { /** * Fast-In-Extra-Slow-Out Interpolator */ @JvmStatic - val interpolator: Interpolator = FastOutSlowInInterpolator() + val interpolator: Interpolator = createDefaultCubicBezierInterpolator() + + val toolIconInterpolator = LinearInterpolator() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index fa2cd294c0..9cb617adb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -29,8 +29,8 @@ import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.mediasend.MediaTransform import org.thoughtcrime.securesms.mediasend.MediaUploadRepository import org.thoughtcrime.securesms.mediasend.SentMediaQualityTransform -import org.thoughtcrime.securesms.mediasend.VideoEditorFragment 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.MediaConstraints @@ -270,7 +270,7 @@ class MediaSelectionRepository(context: Context) { } } - if (state is VideoEditorFragment.Data && state.isDurationEdited) { + if (state is VideoTrimData && state.isDurationEdited) { modelsToRender[it] = VideoTrimTransform(state) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt index 65703b5cbc..6ed0269cad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt @@ -5,10 +5,14 @@ import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendConstants +import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData +import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.video.TranscodingPreset data class MediaSelectionState( val sendType: MessageSendType, @@ -17,7 +21,7 @@ data class MediaSelectionState( val recipient: Recipient? = null, val quality: SentMediaQuality = SignalStore.settings().sentMediaQuality, val message: CharSequence? = null, - val viewOnceToggleState: ViewOnceToggleState = ViewOnceToggleState.INFINITE, + val viewOnceToggleState: ViewOnceToggleState = ViewOnceToggleState.default, val isTouchEnabled: Boolean = true, val isSent: Boolean = false, val isPreUploadEnabled: Boolean = false, @@ -29,6 +33,10 @@ data class MediaSelectionState( val suppressEmptyError: Boolean = true ) { + val isVideoTrimmingVisible: Boolean = focusedMedia != null && MediaUtil.isVideoType(focusedMedia.mimeType) && MediaConstraints.isVideoTranscodeAvailable() && !focusedMedia.isVideoGif + + val transcodingPreset: TranscodingPreset = MediaConstraints.getPushMediaConstraints(SentMediaQuality.fromCode(quality.code)).videoTranscodingSettings + val maxSelection = if (sendType.usesSmsTransport) { MediaSendConstants.MAX_SMS } else { @@ -37,9 +45,12 @@ data class MediaSelectionState( val canSend = !isSent && selectedMedia.isNotEmpty() + fun getVideoTrimData(uri: Uri): VideoTrimData? { + return editorStateMap[uri] as? VideoTrimData + } + enum class ViewOnceToggleState(val code: Int) { - INFINITE(0), - ONCE(1); + INFINITE(0), ONCE(1); fun next(): ViewOnceToggleState { return when (this) { @@ -49,6 +60,8 @@ data class MediaSelectionState( } companion object { + val default = INFINITE + fun fromCode(code: Int): ViewOnceToggleState { return when (code) { 1 -> ONCE diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index 75c7918234..045d3f82a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -25,23 +25,26 @@ import io.reactivex.rxjava3.subjects.Subject import org.signal.core.util.BreakIteratorCompat import org.signal.core.util.getParcelableArrayListCompat import org.signal.core.util.getParcelableCompat +import org.signal.core.util.logging.Log 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.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult -import org.thoughtcrime.securesms.mediasend.VideoEditorFragment import org.thoughtcrime.securesms.mediasend.v2.review.AddMessageCharacterCount +import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.stories.Stories +import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.livedata.Store import java.util.Collections +import kotlin.time.Duration.Companion.milliseconds /** * ViewModel which maintains the list of selected media and other shared values. @@ -58,6 +61,8 @@ class MediaSelectionViewModel( private val identityChangesSince: Long = System.currentTimeMillis() ) : ViewModel() { + private val TAG = Log.tag(MediaSelectionViewModel::class.java) + private val selectedMediaSubject: Subject> = BehaviorSubject.create() private val store: Store = Store( @@ -181,9 +186,16 @@ class MediaSelectionViewModel( .subscribe { filterResult -> if (filterResult.filteredMedia.isNotEmpty()) { store.update { + val initializedVideoEditorStates = filterResult.filteredMedia.filterNot { media -> it.editorStateMap.containsKey(media.uri) } + .filter { media -> MediaUtil.isNonGifVideo(media) } + .associate { video: Media -> + val duration = video.duration.milliseconds.inWholeMicroseconds + video.uri to VideoTrimData(false, duration, 0, duration) + } it.copy( selectedMedia = filterResult.filteredMedia, - focusedMedia = it.focusedMedia ?: filterResult.filteredMedia.first() + focusedMedia = it.focusedMedia ?: filterResult.filteredMedia.first(), + editorStateMap = it.editorStateMap + initializedVideoEditorStates ) } @@ -290,16 +302,17 @@ class MediaSelectionViewModel( } } - fun setFocusedMedia(media: Media) { + fun onPageChanged(media: Media) { store.update { it.copy(focusedMedia = media) } } - fun setFocusedMedia(position: Int) { + fun onPageChanged(position: Int) { store.update { if (position >= it.selectedMedia.size) { it.copy(focusedMedia = null) } else { - it.copy(focusedMedia = it.selectedMedia[position]) + val focusedMedia: Media = it.selectedMedia[position] + it.copy(focusedMedia = focusedMedia) } } } @@ -484,7 +497,7 @@ class MediaSelectionViewModel( val value: Any = if (getBoolean(BUNDLE_IS_IMAGE)) { ImageEditorFragment.Data(this) } else { - VideoEditorFragment.Data.fromBundle(this) + VideoTrimData.fromBundle(this) } return key to value @@ -499,8 +512,8 @@ class MediaSelectionViewModel( } } - is VideoEditorFragment.Data -> { - value.bundle.apply { + is VideoTrimData -> { + value.toBundle().apply { putParcelable(BUNDLE_URI, key) putBoolean(BUNDLE_IS_IMAGE, false) } @@ -527,6 +540,23 @@ class MediaSelectionViewModel( private const val STATE_CAMERA_FIRST_CAPTURE = "$STATE_PREFIX.camera_first_capture" private const val STATE_EDITORS = "$STATE_PREFIX.editors" private const val STATE_EDITOR_COUNT = "$STATE_PREFIX.editor_count" + + @JvmStatic + fun clampToMaxClipDuration(data: VideoTrimData, maxVideoDurationUs: Long, clampEnd: Boolean): VideoTrimData { + if (!MediaConstraints.isVideoTranscodeAvailable()) { + return data + } + + if ((data.endTimeUs - data.startTimeUs) <= maxVideoDurationUs) { + return data + } + + return data.copy( + isDurationEdited = true, + startTimeUs = if (!clampEnd) data.endTimeUs - maxVideoDurationUs else data.startTimeUs, + endTimeUs = if (clampEnd) data.startTimeUs + maxVideoDurationUs else data.endTimeUs + ) + } } class Factory( diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaSelectionGalleryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaSelectionGalleryFragment.kt index 455856a576..569126564e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaSelectionGalleryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaSelectionGalleryFragment.kt @@ -125,7 +125,7 @@ class MediaSelectionGalleryFragment : Fragment(R.layout.fragment_container), Med } override fun onSelectedMediaClicked(media: Media) { - sharedViewModel.setFocusedMedia(media) + sharedViewModel.onPageChanged(media) navigator.goToReview(findNavController()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt index 1362fdf9bd..61cf5c6e0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt @@ -16,6 +16,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import org.signal.core.util.EditTextUtil import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.emoji.MediaKeyboard @@ -94,6 +95,12 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a binding.content.emojiToggle.visible = false } else { binding.content.emojiToggle.setOnClickListener { onEmojiToggleClicked() } + if (requireArguments().getBoolean(ARG_INITIAL_EMOJI_TOGGLE) && view is KeyboardAwareLinearLayout) { + view.addOnKeyboardShownListener { + onEmojiToggleClicked() + view.removeOnKeyboardShownListener(this) + } + } } binding.hud.setOnClickListener { dismissAllowingStateLoss() } @@ -276,11 +283,13 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a const val TAG = "ADD_MESSAGE_DIALOG_FRAGMENT" private const val ARG_INITIAL_TEXT = "arg.initial.text" + private const val ARG_INITIAL_EMOJI_TOGGLE = "arg.initial.emojiToggle" - fun show(fragmentManager: FragmentManager, initialText: CharSequence?) { + fun show(fragmentManager: FragmentManager, initialText: CharSequence?, startWithEmojiKeyboard: Boolean) { AddMessageDialogFragment().apply { arguments = Bundle().apply { putCharSequence(ARG_INITIAL_TEXT, initialText) + putBoolean(ARG_INITIAL_EMOJI_TOGGLE, startWithEmojiKeyboard) } }.show(fragmentManager, TAG) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewAnimatorController.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewAnimatorController.kt index cdd919da92..910ae16dbc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewAnimatorController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewAnimatorController.kt @@ -4,35 +4,44 @@ import android.animation.Animator import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.view.View +import android.view.animation.Interpolator import androidx.core.animation.doOnEnd -import org.thoughtcrime.securesms.util.ContextUtil +import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations import org.thoughtcrime.securesms.util.visible object MediaReviewAnimatorController { - fun getSlideInAnimator(view: View): Animator { - return if (ContextUtil.getAnimationScale(view.context) == 0f) { - view.translationY = 0f - ValueAnimator.ofFloat(0f, 1f) - } else { - ObjectAnimator.ofFloat(view, "translationY", view.translationY, 0f) - } - } - - fun getFadeInAnimator(view: View, isEnabled: Boolean = true): Animator { + fun getFadeInAnimator(view: View, interpolator: Interpolator = MediaAnimations.interpolator, isEnabled: Boolean = true): Animator { view.visible = true view.isEnabled = isEnabled - return ObjectAnimator.ofFloat(view, "alpha", view.alpha, 1f) + return ObjectAnimator.ofFloat(view, "alpha", view.alpha, 1f).apply { + setInterpolator(interpolator) + } } - fun getFadeOutAnimator(view: View, isEnabled: Boolean = false): Animator { + fun getFadeOutAnimator(view: View, interpolator: Interpolator = MediaAnimations.interpolator, isEnabled: Boolean = false): Animator { view.isEnabled = isEnabled - val animator = ObjectAnimator.ofFloat(view, "alpha", view.alpha, 0f) + val animator = ObjectAnimator.ofFloat(view, "alpha", view.alpha, 0f).apply { + setInterpolator(interpolator) + } animator.doOnEnd { view.visible = false } return animator } + + fun getHeightAnimator(view: View, start: Int, end: Int, interpolator: Interpolator = MediaAnimations.interpolator): Animator { + return ValueAnimator.ofInt(start, end).apply { + setInterpolator(interpolator) + addUpdateListener { + val animatedValue = it.animatedValue as Int + val layoutParams = view.layoutParams + layoutParams.height = animatedValue + view.layoutParams = layoutParams + } + duration = 120 + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index cb3f5f0d67..1f4cc3fb34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -2,9 +2,13 @@ package org.thoughtcrime.securesms.mediasend.v2.review import android.animation.Animator import android.animation.AnimatorSet +import android.content.Context import android.content.res.ColorStateList import android.graphics.Color +import android.graphics.Rect +import android.net.Uri import android.os.Bundle +import android.provider.OpenableColumns import android.view.View import android.view.ViewGroup import android.widget.ImageView @@ -17,6 +21,7 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.ViewCompat +import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController @@ -25,9 +30,11 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import app.cash.exhaustive.Exhaustive import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.imageview.ShapeableImageView import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.SimpleTask +import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType @@ -35,6 +42,9 @@ import org.thoughtcrime.securesms.conversation.ScheduleMessageContextMenu import org.thoughtcrime.securesms.conversation.ScheduleMessageTimePickerBottomSheet 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.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.mediasend.v2.HudCommand import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations @@ -44,16 +54,25 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel import org.thoughtcrime.securesms.mediasend.v2.MediaValidator import org.thoughtcrime.securesms.mediasend.v2.stories.StoriesMultiselectForwardActivity +import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.scribbles.ImageEditorFragment +import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.MemoryUnitFormat import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter 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 +import kotlin.math.roundToInt /** * Allows the user to view and edit selected media. @@ -73,23 +92,28 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul private lateinit var sendButton: ImageView private lateinit var addMediaButton: View private lateinit var viewOnceButton: ViewSwitcher - private lateinit var viewOnceMessage: TextView + private lateinit var emojiButton: ShapeableImageView private lateinit var addMessageButton: TextView - private lateinit var addMessageEntry: TextView private lateinit var recipientDisplay: TextView private lateinit var pager: ViewPager2 private lateinit var controls: ConstraintLayout private lateinit var selectionRecycler: RecyclerView private lateinit var controlsShade: View + private lateinit var videoTimeLine: VideoThumbnailsRangeSelectorView + private lateinit var videoSizeHint: TextView + private lateinit var videoTimelinePlaceholder: View private lateinit var progress: ProgressBar private lateinit var progressWrapper: TouchInterceptingFrameLayout + private val exclusionZone = listOf(Rect()) private val navigator = MediaSelectionNavigator( toGallery = R.id.action_mediaReviewFragment_to_mediaGalleryFragment ) private var animatorSet: AnimatorSet? = null private var disposables: LifecycleDisposable = LifecycleDisposable() + private var sentMediaQuality: SentMediaQuality = SignalStore.settings().sentMediaQuality + private var viewOnceToggleState: MediaSelectionState.ViewOnceToggleState = MediaSelectionState.ViewOnceToggleState.default private var scheduledSendTime: Long? = null @@ -109,16 +133,18 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul sendButton = view.findViewById(R.id.send) addMediaButton = view.findViewById(R.id.add_media) viewOnceButton = view.findViewById(R.id.view_once_toggle) + emojiButton = view.findViewById(R.id.emoji_button) addMessageButton = view.findViewById(R.id.add_a_message) - addMessageEntry = view.findViewById(R.id.add_a_message_entry) recipientDisplay = view.findViewById(R.id.recipient) pager = view.findViewById(R.id.media_pager) controls = view.findViewById(R.id.controls) selectionRecycler = view.findViewById(R.id.selection_recycler) controlsShade = view.findViewById(R.id.controls_shade) - viewOnceMessage = view.findViewById(R.id.view_once_message) progress = view.findViewById(R.id.progress) progressWrapper = view.findViewById(R.id.progress_wrapper) + videoTimeLine = view.findViewById(R.id.video_timeline) + videoSizeHint = view.findViewById(R.id.video_size_hint) + videoTimelinePlaceholder = view.findViewById(R.id.timeline_placeholder) DrawableCompat.setTint(progress.indeterminateDrawable, Color.WHITE) progressWrapper.setOnInterceptTouchEventListener { true } @@ -134,6 +160,14 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul pager.adapter = pagerAdapter + controls.addOnLayoutChangeListener { v, left, _, right, _, _, _, _, _ -> + val outRect: Rect = exclusionZone[0] + videoTimeLine.getHitRect(outRect) + outRect.left = left + outRect.right = right + ViewCompat.setSystemGestureExclusionRects(v, exclusionZone) + } + drawToolButton.setOnClickListener { sharedViewModel.sendCommand(HudCommand.StartDraw) } @@ -143,7 +177,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul } qualityButton.setOnClickListener { - QualitySelectorBottomSheetDialog.show(parentFragmentManager) + QualitySelectorBottomSheet().show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) } saveButton.setOnClickListener { @@ -241,12 +275,14 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul sharedViewModel.incrementViewOnceState() } - addMessageButton.setOnClickListener { - AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message) + if (!SignalStore.settings().isPreferSystemEmoji) { + emojiButton.setOnClickListener { + AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message, true) + } } - addMessageEntry.setOnClickListener { - AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message) + addMessageButton.setOnClickListener { + AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message, false) } if (sharedViewModel.isReply) { @@ -255,7 +291,9 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { - sharedViewModel.setFocusedMedia(position) + qualityButton.alpha = 0f + saveButton.alpha = 0f + sharedViewModel.onPageChanged(position) } }) @@ -267,7 +305,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul if (isSelected) { sharedViewModel.removeMedia(media) } else { - sharedViewModel.setFocusedMedia(media) + sharedViewModel.onPageChanged(media) } } selectionRecycler.adapter = selectionAdapter @@ -283,9 +321,23 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul presentSendButton(state.sendType, state.recipient) presentPager(state) presentAddMessageEntry(state.message) - presentImageQualityToggle(state.quality) + presentImageQualityToggle(state) + if (state.quality != sentMediaQuality) { + presentQualityToggleToast(state) + } + sentMediaQuality = state.quality viewOnceButton.displayedChild = if (state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE) 1 else 0 + if (state.viewOnceToggleState != viewOnceToggleState && + state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE && + state.selectedMedia.size == 1 + ) { + presentViewOnceToggleToast(MediaUtil.isNonGifVideo(state.selectedMedia[0])) + } + viewOnceToggleState = state.viewOnceToggleState + + presentVideoTimeline(state) + presentVideoSizeHint(state) computeViewStateAndAnimate(state) } @@ -305,6 +357,56 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul ) } + private fun presentViewOnceToggleToast(isVideo: Boolean) { + val description = if (isVideo) { + getString(R.string.MediaReviewFragment__video_set_to_view_once) + } else { + getString(R.string.MediaReviewFragment__photo_set_to_view_once) + } + + MediaReviewToastPopupWindow.show(controls, R.drawable.ic_view_once_24, description) + } + + private fun presentQualityToggleToast(state: MediaSelectionState) { + val mediaList = state.selectedMedia + if (mediaList.isEmpty()) { + return + } + + val description = if (mediaList.size == 1) { + val media: Media = mediaList[0] + if (MediaUtil.isNonGifVideo(media)) { + if (state.quality == SentMediaQuality.HIGH) { + getString(R.string.MediaReviewFragment__video_set_to_high_quality) + } else { + getString(R.string.MediaReviewFragment__video_set_to_standard_quality) + } + } else if (MediaUtil.isImageType(media.mimeType)) { + if (state.quality == SentMediaQuality.HIGH) { + getString(R.string.MediaReviewFragment__photo_set_to_high_quality) + } else { + getString(R.string.MediaReviewFragment__photo_set_to_standard_quality) + } + } else { + Log.i(TAG, "Could not display quality toggle toast for attachment of type: ${media.mimeType}") + return + } + } else { + if (state.quality == SentMediaQuality.HIGH) { + getString(R.string.MediaReviewFragment__items_set_to_high_quality, mediaList.size) + } else { + getString(R.string.MediaReviewFragment__items_set_to_standard_quality, mediaList.size) + } + } + + val icon = when (state.quality) { + SentMediaQuality.HIGH -> R.drawable.symbol_quality_high_24 + else -> R.drawable.symbol_quality_high_slash_24 + } + + MediaReviewToastPopupWindow.show(controls, icon, description) + } + override fun onResume() { super.onResume() sharedViewModel.kick() @@ -358,14 +460,25 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul } private fun presentAddMessageEntry(message: CharSequence?) { - addMessageEntry.setText(message, TextView.BufferType.SPANNABLE) + if (!message.isNullOrEmpty()) { + addMessageButton.setText(message, TextView.BufferType.SPANNABLE) + } } - private fun presentImageQualityToggle(quality: SentMediaQuality) { + private fun presentImageQualityToggle(state: MediaSelectionState) { + qualityButton.updateLayoutParams { + if (MediaUtil.isImageAndNotGif(state.focusedMedia?.mimeType ?: "")) { + startToStart = ConstraintLayout.LayoutParams.UNSET + startToEnd = cropAndRotateButton.id + } else { + startToStart = ConstraintLayout.LayoutParams.PARENT_ID + startToEnd = ConstraintLayout.LayoutParams.UNSET + } + } qualityButton.setImageResource( - when (quality) { - SentMediaQuality.STANDARD -> R.drawable.ic_sq_24 - SentMediaQuality.HIGH -> R.drawable.ic_hq_24 + when (state.quality) { + SentMediaQuality.STANDARD -> R.drawable.symbol_quality_high_slash_24 + SentMediaQuality.HIGH -> R.drawable.symbol_quality_high_24 } ) } @@ -408,12 +521,48 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul } } + private fun presentVideoTimeline(state: MediaSelectionState) { + val mediaItem = state.focusedMedia ?: return + if (!MediaUtil.isVideoType(mediaItem.mimeType) || !MediaConstraints.isVideoTranscodeAvailable()) { + return + } + val uri = mediaItem.uri + videoTimeLine.setInput(DecryptableUriMediaInput.createForUri(requireContext(), uri)) + val size: Long = tryGetUriSize(requireContext(), uri, Long.MAX_VALUE) + val maxSend = sharedViewModel.getMediaConstraints().getVideoMaxSize(requireContext()) + if (size > maxSend) { + videoTimeLine.setTimeLimit(state.transcodingPreset.calculateMaxVideoUploadDurationInSeconds(maxSend), TimeUnit.SECONDS) + } + + if (state.isTouchEnabled) { + val data = state.getVideoTrimData(uri) ?: return + + if (data.totalInputDurationUs > 0) { + videoTimeLine.setRange(data.startTimeUs, data.endTimeUs) + } + } + } + + private fun presentVideoSizeHint(state: MediaSelectionState) { + val focusedMedia = state.focusedMedia ?: return + val trimData = state.getVideoTrimData(focusedMedia.uri) + + videoSizeHint.text = if (state.isVideoTrimmingVisible && trimData != null) { + val seconds = trimData.getDuration().inWholeSeconds + val bytes = TranscodingQuality.createFromPreset(state.transcodingPreset, trimData.getDuration().inWholeMilliseconds).byteCountEstimate + String.format(Locale.getDefault(), "%d:%02d • %s", seconds / 60, seconds % 60, MemoryUnitFormat.formatBytes(bytes, MemoryUnitFormat.MEGA_BYTES, true)) + } else { + null + } + } + private fun computeViewStateAndAnimate(state: MediaSelectionState) { this.animatorSet?.cancel() val animators = mutableListOf() animators.addAll(computeAddMessageAnimators(state)) + animators.addAll(computeEmojiButtonAnimators(state)) animators.addAll(computeViewOnceButtonAnimators(state)) animators.addAll(computeAddMediaButtonsAnimators(state)) animators.addAll(computeSendButtonAnimators(state)) @@ -423,53 +572,69 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul animators.addAll(computeDrawToolButtonAnimators(state)) animators.addAll(computeRecipientDisplayAnimators(state)) animators.addAll(computeControlsShadeAnimators(state)) + animators.addAll(computeVideoTimelineAnimator(state)) val animatorSet = AnimatorSet() animatorSet.playTogether(animators) - animatorSet.interpolator = MediaAnimations.interpolator animatorSet.start() this.animatorSet = animatorSet } private fun computeControlsShadeAnimators(state: MediaSelectionState): List { - return if (state.isTouchEnabled) { - listOf(MediaReviewAnimatorController.getFadeInAnimator(controlsShade)) + val animators = mutableListOf() + animators += if (state.isTouchEnabled) { + MediaReviewAnimatorController.getFadeInAnimator(controlsShade) } else { - listOf(MediaReviewAnimatorController.getFadeOutAnimator(controlsShade)) + MediaReviewAnimatorController.getFadeOutAnimator(controlsShade) } + + animators += if (state.isVideoTrimmingVisible) { + MediaReviewAnimatorController.getHeightAnimator(videoTimelinePlaceholder, videoTimelinePlaceholder.height, resources.getDimension(R.dimen.video_timeline_height_expanded).roundToInt()) + } else { + MediaReviewAnimatorController.getHeightAnimator(videoTimelinePlaceholder, videoTimelinePlaceholder.height, resources.getDimension(R.dimen.video_timeline_height_collapsed).roundToInt()) + } + + return animators + } + + private fun computeVideoTimelineAnimator(state: MediaSelectionState): List { + val animators = mutableListOf() + + if (state.isVideoTrimmingVisible) { + animators += MediaReviewAnimatorController.getFadeInAnimator(videoTimeLine).apply { + startDelay = 100 + duration = 500 + } + } else { + animators += MediaReviewAnimatorController.getFadeOutAnimator(videoTimeLine).apply { + duration = 400 + } + } + + animators += if (state.isVideoTrimmingVisible && state.isTouchEnabled) { + MediaReviewAnimatorController.getFadeInAnimator(videoSizeHint).apply { + startDelay = 100 + duration = 500 + } + } else { + MediaReviewAnimatorController.getFadeOutAnimator(videoSizeHint).apply { + duration = 400 + } + } + + return animators } private fun computeAddMessageAnimators(state: MediaSelectionState): List { - return when { - !state.isTouchEnabled -> { - listOf( - MediaReviewAnimatorController.getFadeOutAnimator(viewOnceMessage), - MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton), - MediaReviewAnimatorController.getFadeOutAnimator(addMessageEntry) - ) - } - state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE -> { - listOf( - MediaReviewAnimatorController.getFadeInAnimator(viewOnceMessage), - MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton), - MediaReviewAnimatorController.getFadeOutAnimator(addMessageEntry) - ) - } - state.message.isNullOrEmpty() -> { - listOf( - MediaReviewAnimatorController.getFadeOutAnimator(viewOnceMessage), - MediaReviewAnimatorController.getFadeInAnimator(addMessageButton), - MediaReviewAnimatorController.getFadeOutAnimator(addMessageEntry) - ) - } - else -> { - listOf( - MediaReviewAnimatorController.getFadeOutAnimator(viewOnceMessage), - MediaReviewAnimatorController.getFadeInAnimator(addMessageEntry), - MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton) - ) - } + return if (!state.isTouchEnabled) { + listOf( + MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton) + ) + } else { + listOf( + MediaReviewAnimatorController.getFadeInAnimator(addMessageButton) + ) } } @@ -481,6 +646,14 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul } } + private fun computeEmojiButtonAnimators(state: MediaSelectionState): List { + return if (state.isTouchEnabled && !state.isStory && !SignalStore.settings().isPreferSystemEmoji) { + listOf(MediaReviewAnimatorController.getFadeInAnimator(emojiButton)) + } else { + listOf(MediaReviewAnimatorController.getFadeOutAnimator(emojiButton)) + } + } + private fun computeAddMediaButtonsAnimators(state: MediaSelectionState): List { return when { !state.isTouchEnabled || state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE -> { @@ -505,64 +678,50 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul } private fun computeSendButtonAnimators(state: MediaSelectionState): List { - val slideIn = listOf( - MediaReviewAnimatorController.getSlideInAnimator(sendButton) - ) - - return slideIn + if (state.isTouchEnabled) { + return if (state.isTouchEnabled) { listOf( - MediaReviewAnimatorController.getFadeInAnimator(sendButton, state.canSend) + MediaReviewAnimatorController.getFadeInAnimator(sendButton, isEnabled = state.canSend) ) } else { listOf( - MediaReviewAnimatorController.getFadeOutAnimator(sendButton, state.canSend) + MediaReviewAnimatorController.getFadeOutAnimator(sendButton, isEnabled = state.canSend) ) } } private fun computeSaveButtonAnimators(state: MediaSelectionState): List { - val slideIn = listOf( - MediaReviewAnimatorController.getSlideInAnimator(saveButton) - ) - - return slideIn + if (state.isTouchEnabled && !MediaUtil.isVideo(state.focusedMedia?.mimeType)) { + return if (state.isTouchEnabled && !MediaUtil.isVideo(state.focusedMedia?.mimeType)) { listOf( - MediaReviewAnimatorController.getFadeInAnimator(saveButton) + MediaReviewAnimatorController.getFadeInAnimator(saveButton, MediaAnimations.toolIconInterpolator) ) } else { listOf( - MediaReviewAnimatorController.getFadeOutAnimator(saveButton) + MediaReviewAnimatorController.getFadeOutAnimator(saveButton, MediaAnimations.toolIconInterpolator) ) } } private fun computeQualityButtonAnimators(state: MediaSelectionState): List { - val slide = listOf(MediaReviewAnimatorController.getSlideInAnimator(qualityButton)) - - return slide + if (state.isTouchEnabled && !state.isStory && state.selectedMedia.any { MediaUtil.isImageType(it.mimeType) }) { - listOf(MediaReviewAnimatorController.getFadeInAnimator(qualityButton)) + return if (state.isTouchEnabled && !state.isStory) { + listOf(MediaReviewAnimatorController.getFadeInAnimator(qualityButton, MediaAnimations.toolIconInterpolator)) } else { - listOf(MediaReviewAnimatorController.getFadeOutAnimator(qualityButton)) + listOf(MediaReviewAnimatorController.getFadeOutAnimator(qualityButton, MediaAnimations.toolIconInterpolator)) } } private fun computeCropAndRotateButtonAnimators(state: MediaSelectionState): List { - val slide = listOf(MediaReviewAnimatorController.getSlideInAnimator(cropAndRotateButton)) - - return slide + if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.mimeType ?: "")) { - listOf(MediaReviewAnimatorController.getFadeInAnimator(cropAndRotateButton)) + return if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.mimeType ?: "")) { + listOf(MediaReviewAnimatorController.getFadeInAnimator(cropAndRotateButton, MediaAnimations.toolIconInterpolator)) } else { - listOf(MediaReviewAnimatorController.getFadeOutAnimator(cropAndRotateButton)) + listOf(MediaReviewAnimatorController.getFadeOutAnimator(cropAndRotateButton, MediaAnimations.toolIconInterpolator)) } } private fun computeDrawToolButtonAnimators(state: MediaSelectionState): List { - val slide = listOf(MediaReviewAnimatorController.getSlideInAnimator(drawToolButton)) - - return slide + if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.mimeType ?: "")) { - listOf(MediaReviewAnimatorController.getFadeInAnimator(drawToolButton)) + return if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.mimeType ?: "")) { + listOf(MediaReviewAnimatorController.getFadeInAnimator(drawToolButton, MediaAnimations.toolIconInterpolator)) } else { - listOf(MediaReviewAnimatorController.getFadeOutAnimator(drawToolButton)) + listOf(MediaReviewAnimatorController.getFadeOutAnimator(drawToolButton, MediaAnimations.toolIconInterpolator)) } } @@ -575,6 +734,29 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul } } + companion object { + private val TAG = Log.tag(MediaReviewFragment::class.java) + + @JvmStatic + private fun tryGetUriSize(context: Context, uri: Uri, defaultValue: Long): Long { + return try { + var size: Long = 0 + context.contentResolver.query(uri, null, null, null, null).use { cursor -> + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) + } + } + if (size <= 0) { + size = MediaUtil.getMediaSize(context, uri) + } + size + } catch (e: IOException) { + Log.w(TAG, e) + defaultValue + } + } + } + interface Callback { fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult) fun onSentWithoutResult() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewToastPopupWindow.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewToastPopupWindow.kt new file mode 100644 index 0000000000..a17fd4ce19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewToastPopupWindow.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediasend.v2.review + +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.PopupWindow +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.ViewUtil +import kotlin.time.Duration.Companion.seconds + +/** + * Toast-style notification used in the media review flow. This exists so we can specify the location and animation of how it appears. + */ +class MediaReviewToastPopupWindow private constructor(parent: ViewGroup, iconResource: Int, descriptionText: String) : PopupWindow( + LayoutInflater.from(parent.context).inflate(R.layout.v2_media_review_quality_popup_window, parent, false), + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT +) { + + private val icon: ImageView = contentView.findViewById(R.id.media_review_toast_popup_icon) + private val description: TextView = contentView.findViewById(R.id.media_review_toast_popup_description) + + init { + elevation = ViewUtil.dpToPx(8).toFloat() + animationStyle = R.style.StickerPopupAnimation + icon.setImageResource(iconResource) + description.text = descriptionText + } + + private fun show(parent: ViewGroup) { + showAtLocation(parent, Gravity.CENTER, 0, 0) + measureChild() + update() + contentView.postDelayed({ dismiss() }, DURATION) + } + + private fun measureChild() { + contentView.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + } + + companion object { + private val DURATION = 3.seconds.inWholeMilliseconds + + @JvmStatic + fun show(parent: ViewGroup, icon: Int, description: String): MediaReviewToastPopupWindow { + val qualityToast = MediaReviewToastPopupWindow(parent, icon, description) + qualityToast.show(parent) + return qualityToast + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheet.kt new file mode 100644 index 0000000000..24c68ace4d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheet.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediasend.v2.review + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.viewModels +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel +import org.thoughtcrime.securesms.mms.SentMediaQuality + +/** + * Bottom sheet dialog to select the media quality (Standard vs. High) when sending media. + */ +class QualitySelectorBottomSheet : ComposeBottomSheetDialogFragment() { + private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() }) + + override val forceDarkTheme = true + + @Composable + override fun SheetContent() { + val state by sharedViewModel.state.observeAsState() + val quality = state?.quality + if (quality != null) { + Content(quality = quality, onQualitySelected = { + sharedViewModel.setSentMediaQuality(it) + dismiss() + }) + } + } +} + +@Composable +private fun Content(quality: SentMediaQuality, onQualitySelected: (SentMediaQuality) -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) { + BottomSheets.Handle(modifier = Modifier.padding(top = 6.dp)) + } + + Text( + text = stringResource(id = R.string.QualitySelectorBottomSheetDialog__media_quality), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 20.dp, bottom = 14.dp) + ) + Row(horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp)) { + val standardQuality = quality == SentMediaQuality.STANDARD + Button( + onClick = { onQualitySelected(SentMediaQuality.STANDARD) }, + shape = RoundedCornerShape(percent = 25), + colors = if (standardQuality) ButtonDefaults.filledTonalButtonColors() else ButtonDefaults.textButtonColors(), + elevation = if (standardQuality) ButtonDefaults.filledTonalButtonElevation() else null, + contentPadding = if (standardQuality) ButtonDefaults.ContentPadding else ButtonDefaults.TextButtonContentPadding + ) { + ButtonLabel(title = stringResource(id = R.string.QualitySelectorBottomSheetDialog__standard), description = stringResource(id = R.string.QualitySelectorBottomSheetDialog__faster_less_data)) + } + Button( + onClick = { onQualitySelected(SentMediaQuality.HIGH) }, + shape = RoundedCornerShape(percent = 25), + colors = if (!standardQuality) ButtonDefaults.filledTonalButtonColors() else ButtonDefaults.textButtonColors(), + elevation = if (!standardQuality) ButtonDefaults.filledTonalButtonElevation() else null, + contentPadding = if (!standardQuality) ButtonDefaults.ContentPadding else ButtonDefaults.TextButtonContentPadding + ) { + ButtonLabel(title = stringResource(id = R.string.QualitySelectorBottomSheetDialog__high), description = stringResource(id = R.string.QualitySelectorBottomSheetDialog__slower_more_data)) + } + } + } +} + +@Composable +private fun ButtonLabel(title: String, description: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = title, color = MaterialTheme.colorScheme.onSurface) + Text(text = description, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodySmall) + } +} + +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewQualitySelectorBottomSheetStandard() { + SignalTheme(isDarkMode = true) { + Content(SentMediaQuality.STANDARD) {} + } +} + +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewQualitySelectorBottomSheetHigh() { + SignalTheme(isDarkMode = true) { + Content(SentMediaQuality.HIGH) {} + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheetDialog.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheetDialog.java deleted file mode 100644 index 5e985a952f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheetDialog.java +++ /dev/null @@ -1,91 +0,0 @@ -package org.thoughtcrime.securesms.mediasend.v2.review; - -import android.os.Bundle; -import android.view.ContextThemeWrapper; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentManager; -import androidx.lifecycle.ViewModelProvider; - -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState; -import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel; -import org.thoughtcrime.securesms.mms.SentMediaQuality; -import org.thoughtcrime.securesms.util.BottomSheetUtil; -import org.thoughtcrime.securesms.util.WindowUtil; -import org.thoughtcrime.securesms.util.views.CheckedLinearLayout; - -/** - * Dialog for selecting media quality, tightly coupled with {@link MediaSelectionViewModel}. - */ -public final class QualitySelectorBottomSheetDialog extends BottomSheetDialogFragment { - - private MediaSelectionViewModel viewModel; - private CheckedLinearLayout standard; - private CheckedLinearLayout high; - - public static void show(@NonNull FragmentManager manager) { - QualitySelectorBottomSheetDialog fragment = new QualitySelectorBottomSheetDialog(); - - fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet); - super.onCreate(savedInstanceState); - } - - @Override - public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - ContextThemeWrapper contextThemeWrapper = new ContextThemeWrapper(inflater.getContext(), R.style.TextSecure_DarkTheme); - LayoutInflater themedInflater = LayoutInflater.from(contextThemeWrapper); - - return themedInflater.inflate(R.layout.quality_selector_dialog, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) { - standard = view.findViewById(R.id.quality_selector_dialog_standard); - high = view.findViewById(R.id.quality_selector_dialog_high); - - View.OnClickListener listener = v -> { - select(v); - view.postDelayed(this::dismissAllowingStateLoss, 250); - }; - - standard.setOnClickListener(listener); - high.setOnClickListener(listener); - - viewModel = new ViewModelProvider(requireActivity()).get(MediaSelectionViewModel.class); - viewModel.getState().observe(getViewLifecycleOwner(), this::updateQuality); - } - - @Override - public void onResume() { - super.onResume(); - WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow()); - } - - private void updateQuality(@NonNull MediaSelectionState selectionState) { - select(selectionState.getQuality() == SentMediaQuality.STANDARD ? standard : high); - } - - private void select(@NonNull View view) { - standard.setChecked(view == standard); - high.setChecked(view == high); - viewModel.setSentMediaQuality(standard == view ? SentMediaQuality.STANDARD : SentMediaQuality.HIGH); - } - - @Override - public void show(@NonNull FragmentManager manager, @Nullable String tag) { - BottomSheetUtil.show(manager, tag, this); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt index 5a462c2498..3669e95b64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt @@ -41,9 +41,7 @@ class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), Vide private fun saveEditorState() { val saveState = videoEditorFragment.saveState() - if (saveState != null) { - sharedViewModel.setEditorState(requireUri(), saveState) - } + sharedViewModel.setEditorState(requireUri(), saveState) } override fun onPlayerReady() { @@ -67,7 +65,7 @@ class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), Vide } private fun restoreVideoEditorState() { - val data = sharedViewModel.getEditorState(requireUri()) as? VideoEditorFragment.Data + val data = sharedViewModel.getEditorState(requireUri()) as? VideoTrimData if (data != null) { videoEditorFragment.restoreState(data) @@ -82,7 +80,6 @@ class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), Vide } else { val videoEditorFragment = VideoEditorFragment.newInstance( requireUri(), - requireMaxCompressedVideoSize(), requireMaxAttachmentSize(), requireIsVideoGif(), requireMaxVideoDuration() @@ -101,8 +98,7 @@ class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), Vide } private fun requireUri(): Uri = requireNotNull(requireArguments().getParcelableCompat(ARG_URI, Uri::class.java)) - private fun requireMaxCompressedVideoSize(): Long = sharedViewModel.getMediaConstraints().getCompressedVideoMaxSize(requireContext()).toLong() - private fun requireMaxAttachmentSize(): Long = sharedViewModel.getMediaConstraints().getVideoMaxSize(requireContext()).toLong() + private fun requireMaxAttachmentSize(): Long = sharedViewModel.getMediaConstraints().getVideoMaxSize(requireContext()) private fun requireIsVideoGif(): Boolean = requireNotNull(requireArguments().getBoolean(ARG_IS_VIDEO_GIF)) private fun requireMaxVideoDuration(): Long = if (sharedViewModel.isStory() && !MediaConstraints.isVideoTranscodeAvailable()) Stories.MAX_VIDEO_DURATION_MILLIS else Long.MAX_VALUE diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/VideoTrimData.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/VideoTrimData.kt new file mode 100644 index 0000000000..2dec255b28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/VideoTrimData.kt @@ -0,0 +1,48 @@ +/* + * 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) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java index ece54111e5..cd60991abc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MemoryFileDescriptor; +import org.thoughtcrime.securesms.video.TranscodingPreset; import java.io.IOException; import java.io.InputStream; @@ -40,6 +41,10 @@ public abstract class MediaConstraints { public abstract int getImageMaxHeight(Context context); public abstract int getImageMaxSize(Context context); + public TranscodingPreset getVideoTranscodingSettings() { + return TranscodingPreset.LEVEL_1; + } + public boolean isHighQuality() { return false; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java index f5d00d8793..d19aec7732 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java @@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.LocaleFeatureFlags; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.video.TranscodingPreset; import java.util.Arrays; @@ -90,6 +91,11 @@ public class PushMediaConstraints extends MediaConstraints { return currentConfig.qualitySetting; } + @Override + public TranscodingPreset getVideoTranscodingSettings() { + return currentConfig.videoPreset; + } + private static @NonNull MediaConfig getCurrentConfig(@NonNull Context context, @Nullable SentMediaQuality sentMediaQuality) { if (Util.isLowMemory(context)) { return MediaConfig.LEVEL_1_LOW_MEMORY; @@ -102,29 +108,32 @@ public class PushMediaConstraints extends MediaConstraints { } public enum MediaConfig { - LEVEL_1_LOW_MEMORY(true, 1, MB, new int[] { 768, 512 }, 70), + LEVEL_1_LOW_MEMORY(true, 1, MB, new int[] { 768, 512 }, 70, TranscodingPreset.LEVEL_1), - LEVEL_1(false, 1, MB, new int[] { 1600, 1024, 768, 512 }, 70), - LEVEL_2(false, 2, (int) (1.5 * MB), new int[] { 2048, 1600, 1024, 768, 512 }, 75), - LEVEL_3(false, 3, (int) (3 * MB), new int[] { 4096, 3072, 2048, 1600, 1024, 768, 512 }, 75); + LEVEL_1(false, 1, MB, new int[] { 1600, 1024, 768, 512 }, 70, TranscodingPreset.LEVEL_1), + LEVEL_2(false, 2, (int) (1.5 * MB), new int[] { 2048, 1600, 1024, 768, 512 }, 75, TranscodingPreset.LEVEL_2), + LEVEL_3(false, 3, (int) (3 * MB), new int[] { 4096, 3072, 2048, 1600, 1024, 768, 512 }, 75, TranscodingPreset.LEVEL_3); - private final boolean isLowMemory; - private final int level; - private final int maxImageFileSize; - private final int[] imageSizeTargets; - private final int qualitySetting; + private final boolean isLowMemory; + private final int level; + private final int maxImageFileSize; + private final int[] imageSizeTargets; + private final int qualitySetting; + private final TranscodingPreset videoPreset; MediaConfig(boolean isLowMemory, int level, int maxImageFileSize, @NonNull int[] imageSizeTargets, - @IntRange(from = 0, to = 100) int qualitySetting) + @IntRange(from = 0, to = 100) int qualitySetting, + TranscodingPreset videoPreset) { this.isLowMemory = isLowMemory; this.level = level; this.maxImageFileSize = maxImageFileSize; this.imageSizeTargets = imageSizeTargets; this.qualitySetting = qualitySetting; + this.videoPreset = videoPreset; } public int getMaxImageFileSize() { @@ -135,10 +144,14 @@ public class PushMediaConstraints extends MediaConstraints { return imageSizeTargets; } - public int getQualitySetting() { + public int getImageQualitySetting() { return qualitySetting; } + public TranscodingPreset getVideoPreset() { + return videoPreset; + } + public static @Nullable MediaConfig forLevel(int level) { boolean isLowMemory = Util.isLowMemory(ApplicationDependencies.getApplication()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 301b6063fe..8e3314b326 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -320,12 +320,6 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu return imageUri; } - @Nullable - @Override - public View getPlaybackControls() { - return null; - } - @Override public Object saveState() { Data data = new Data(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java deleted file mode 100644 index 62bfc7b624..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java +++ /dev/null @@ -1,226 +0,0 @@ -package org.thoughtcrime.securesms.scribbles; - -import android.animation.Animator; -import android.content.Context; -import android.database.Cursor; -import android.graphics.Rect; -import android.net.Uri; -import android.provider.OpenableColumns; -import android.util.AttributeSet; -import android.view.View; -import android.view.animation.OvershootInterpolator; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.core.view.ViewCompat; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.media.DecryptableUriMediaInput; -import org.thoughtcrime.securesms.mms.VideoSlide; -import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.video.TranscodingQuality; -import org.thoughtcrime.securesms.video.VideoBitRateCalculator; -import org.thoughtcrime.securesms.video.VideoUtil; -import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView; - -import java.io.IOException; -import java.util.List; -import java.util.concurrent.TimeUnit; - -/** - * The HUD (heads-up display) that contains all of the tools for editing video. - */ -public final class VideoEditorHud extends LinearLayout { - - @SuppressWarnings("unused") - private static final String TAG = Log.tag(VideoEditorHud.class); - - private final List exclusionZone = List.of(new Rect()); - - private VideoThumbnailsRangeSelectorView videoTimeLine; - private EventListener eventListener; - private View playOverlay; - - public VideoEditorHud(@NonNull Context context) { - super(context); - initialize(); - } - - public VideoEditorHud(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public VideoEditorHud(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - private void initialize() { - View root = inflate(getContext(), R.layout.video_editor_hud, this); - setOrientation(VERTICAL); - - videoTimeLine = root.findViewById(R.id.video_timeline); - playOverlay = root.findViewById(R.id.play_overlay); - - playOverlay.setOnClickListener(v -> eventListener.onPlay()); - } - - public void setEventListener(EventListener eventListener) { - this.eventListener = eventListener; - } - - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - final Rect outRect = exclusionZone.get(0); - videoTimeLine.getHitRect(outRect); - outRect.left = l; - outRect.right = r; - ViewCompat.setSystemGestureExclusionRects(this, exclusionZone); - super.onLayout(changed, l, t, r, b); - } - - @RequiresApi(api = 23) - public void setVideoSource(@NonNull VideoSlide slide, @NonNull VideoBitRateCalculator videoBitRateCalculator, long maxSendSize) - throws IOException - { - Uri uri = slide.getUri(); - - if (uri == null || !slide.hasVideo()) { - return; - } - - videoTimeLine.setInput(DecryptableUriMediaInput.createForUri(getContext(), uri)); - - long size = tryGetUriSize(getContext(), uri, Long.MAX_VALUE); - - if (size > maxSendSize) { - videoTimeLine.setTimeLimit(videoBitRateCalculator.getMaxVideoUploadDurationInSeconds(), TimeUnit.SECONDS); - } - - videoTimeLine.setOnRangeChangeListener(new VideoThumbnailsRangeSelectorView.OnRangeChangeListener() { - - @Override - public void onPositionDrag(long position) { - if (eventListener != null) { - eventListener.onSeek(position, false); - } - } - - @Override - public void onEndPositionDrag(long position) { - if (eventListener != null) { - eventListener.onSeek(position, true); - } - } - - @Override - public void onRangeDrag(long minValueUs, long maxValueUs, long durationUs, VideoThumbnailsRangeSelectorView.Thumb thumb) { - if (eventListener != null) { - eventListener.onEditVideoDuration(durationUs, minValueUs, maxValueUs, thumb == VideoThumbnailsRangeSelectorView.Thumb.MIN, false); - } - } - - @Override - public void onRangeDragEnd(long minValueUs, long maxValueUs, long durationUs, VideoThumbnailsRangeSelectorView.Thumb thumb) { - if (eventListener != null) { - eventListener.onEditVideoDuration(durationUs, minValueUs, maxValueUs, thumb == VideoThumbnailsRangeSelectorView.Thumb.MIN, true); - } - } - - @Override - public VideoThumbnailsRangeSelectorView.Quality getQuality(long clipDurationUs, long totalDurationUs) { - int inputBitRate = VideoBitRateCalculator.bitRate(size, TimeUnit.MICROSECONDS.toMillis(totalDurationUs)); - - TranscodingQuality targetQuality = videoBitRateCalculator.getTargetQuality(TimeUnit.MICROSECONDS.toMillis(clipDurationUs), inputBitRate); - return new VideoThumbnailsRangeSelectorView.Quality(targetQuality.getFileSizeEstimate(), (int) (100 * targetQuality.getQuality())); - } - }); - } - - public void showPlayButton() { - playOverlay.setVisibility(VISIBLE); - playOverlay.animate() - .setListener(null) - .alpha(1) - .scaleX(1).scaleY(1) - .setInterpolator(new OvershootInterpolator()) - .start(); - } - - public void fadePlayButton() { - playOverlay.animate() - .setListener(new Animator.AnimatorListener() { - @Override - public void onAnimationEnd(Animator animation) { - playOverlay.setVisibility(GONE); - } - - @Override - public void onAnimationStart(Animator animation) {} - - @Override - public void onAnimationCancel(Animator animation) {} - - @Override - public void onAnimationRepeat(Animator animation) {} - }) - .alpha(0) - .scaleX(0.8f).scaleY(0.8f) - .start(); - } - - public void hidePlayButton() { - playOverlay.setVisibility(GONE); - playOverlay.setAlpha(0); - playOverlay.setScaleX(0.8f); - playOverlay.setScaleY(0.8f); - } - - @RequiresApi(api = 23) - public void setDurationRange(long totalDuration, long fromDuration, long toDuration) { - videoTimeLine.setRange(fromDuration, toDuration); - } - - @RequiresApi(api = 23) - public void setPosition(long playbackPositionUs) { - videoTimeLine.setActualPosition(playbackPositionUs); - } - - public interface EventListener { - - void onEditVideoDuration(long totalDurationUs, long startTimeUs, long endTimeUs, boolean fromEdited, boolean editingComplete); - - void onPlay(); - - void onSeek(long position, boolean dragComplete); - } - - private long tryGetUriSize(@NonNull Context context, @NonNull Uri uri, long defaultValue) { - try { - return getSize(context, uri); - } catch (IOException e) { - Log.w(TAG, e); - return defaultValue; - } - } - - private static long getSize(@NonNull Context context, @NonNull Uri uri) throws IOException { - long size = 0; - - try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { - size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); - } - } - - if (size <= 0) { - size = MediaUtil.getMediaSize(context, uri); - } - - return size; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorPlayButtonLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorPlayButtonLayout.kt new file mode 100644 index 0000000000..000aa01e9e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorPlayButtonLayout.kt @@ -0,0 +1,55 @@ +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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Animations.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Animations.kt index 96e0670bec..d49f1bf771 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Animations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Animations.kt @@ -1,6 +1,8 @@ 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 = { }, @@ -21,3 +23,5 @@ fun Animation.setListeners( } }) } + +fun createDefaultCubicBezierInterpolator(): Interpolator = PathInterpolatorCompat.create(0.17f, 0.17f, 0f, 1f) diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java index 82b544fe9e..d4b2f23b7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java @@ -17,8 +17,8 @@ import org.thoughtcrime.securesms.video.exceptions.VideoSizeException; import org.thoughtcrime.securesms.video.exceptions.VideoSourceException; import org.thoughtcrime.securesms.video.interfaces.TranscoderCancelationSignal; import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor; -import org.thoughtcrime.securesms.video.videoconverter.exceptions.EncodingException; import org.thoughtcrime.securesms.video.videoconverter.MediaConverter; +import org.thoughtcrime.securesms.video.videoconverter.exceptions.EncodingException; import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput; import java.io.Closeable; @@ -51,7 +51,7 @@ public final class InMemoryTranscoder implements Closeable { /** * @param upperSizeLimit A upper size to transcode to. The actual output size can be up to 10% smaller. */ - public InMemoryTranscoder(@NonNull Context context, @NonNull MediaDataSource dataSource, @Nullable TranscoderOptions options, long upperSizeLimit) throws IOException, VideoSourceException { + public InMemoryTranscoder(@NonNull Context context, @NonNull MediaDataSource dataSource, @Nullable TranscoderOptions options, @NonNull TranscodingPreset preset, long upperSizeLimit) throws IOException, VideoSourceException { this.context = context; this.dataSource = dataSource; this.options = options; @@ -71,8 +71,8 @@ public final class InMemoryTranscoder implements Closeable { } this.inSize = dataSource.getSize(); - this.inputBitRate = VideoBitRateCalculator.bitRate(inSize, duration); - this.targetQuality = new VideoBitRateCalculator(upperSizeLimit).getTargetQuality(duration, inputBitRate); + this.inputBitRate = TranscodingQuality.bitRate(inSize, duration); + this.targetQuality = TranscodingQuality.createFromPreset(preset, duration); this.upperSizeLimit = upperSizeLimit; this.transcodeRequired = inputBitRate >= targetQuality.getTargetTotalBitRate() * 1.2 || inSize > upperSizeLimit || containsLocation(mediaMetadataRetriever) || options != null; @@ -80,7 +80,7 @@ public final class InMemoryTranscoder implements Closeable { Log.i(TAG, "Video is within 20% of target bitrate, below the size limit, contained no location metadata or custom options."); } - this.fileSizeEstimate = targetQuality.getFileSizeEstimate(); + this.fileSizeEstimate = targetQuality.getByteCountEstimate(); this.memoryFileEstimate = (long) (fileSizeEstimate * 1.1); } @@ -168,7 +168,7 @@ public final class InMemoryTranscoder implements Closeable { (outSize * 100d) / inSize, (outSize * 100d) / fileSizeEstimate, (outSize * 100d) / memoryFileEstimate, - numberFormat.format(VideoBitRateCalculator.bitRate(outSize, duration)))); + numberFormat.format(TranscodingQuality.bitRate(outSize, duration)))); if (outSize > upperSizeLimit) { throw new VideoSizeException("Size constraints could not be met!"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java index a422531476..b9626d2819 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -292,7 +292,7 @@ public class VideoPlayer extends FrameLayout { if (this.exoPlayer != null) { return TimeUnit.MILLISECONDS.toMicros(this.exoPlayer.getCurrentPosition()) + clippedStartUs; } - return 0L; + return -1L; } public void setPlaybackPosition(long positionMs) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java index b5c64af1f5..929dc121ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java @@ -10,21 +10,19 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants; -import java.util.concurrent.TimeUnit; - public final class VideoUtil { private VideoUtil() { } public static Size getVideoRecordingSize() { return isPortrait(screenSize()) - ? new Size(VideoConstants.VIDEO_SHORT_EDGE, VideoConstants.VIDEO_LONG_EDGE) - : new Size(VideoConstants.VIDEO_LONG_EDGE, VideoConstants.VIDEO_SHORT_EDGE); + ? new Size(VideoConstants.VIDEO_SHORT_EDGE_HD, VideoConstants.VIDEO_LONG_EDGE_HD) + : new Size(VideoConstants.VIDEO_LONG_EDGE_HD, VideoConstants.VIDEO_SHORT_EDGE_HD); } public static int getMaxVideoRecordDurationInSeconds(@NonNull Context context, @NonNull MediaConstraints mediaConstraints) { long allowedSize = mediaConstraints.getCompressedVideoMaxSize(context); - int duration = (int) Math.floor((float) allowedSize / VideoConstants.TOTAL_BYTES_PER_SECOND); + int duration = (int) Math.floor((float) allowedSize / VideoConstants.MAX_ALLOWED_BYTES_PER_SECOND); return Math.min(duration, VideoConstants.VIDEO_MAX_RECORD_LENGTH_S); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java index b250626148..72b35c91c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java @@ -6,7 +6,6 @@ import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.RectF; -import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.view.MotionEvent; @@ -15,14 +14,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.annotation.RequiresApi; -import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.MemoryUnitFormat; +import org.thoughtcrime.securesms.util.ViewUtil; import java.util.Locale; -import java.util.Objects; import java.util.concurrent.TimeUnit; @RequiresApi(api = 23) @@ -30,8 +27,9 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView private static final String TAG = Log.tag(VideoThumbnailsRangeSelectorView.class); - private static final long MINIMUM_SELECTABLE_RANGE = TimeUnit.MILLISECONDS.toMicros(500); - private static final int ANIMATION_DURATION_MS = 100; + 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 = ViewUtil.dpToPx(4); private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint paintGrey = new Paint(Paint.ANTI_ALIAS_FLAG); @@ -39,8 +37,6 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView private final Paint thumbTimeBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Rect tempDrawRect = new Rect(); private final RectF timePillRect = new RectF(); - private Drawable chevronLeft; - private Drawable chevronRight; @Px private int left; @Px private int right; @@ -58,10 +54,7 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView private OnRangeChangeListener onRangeChangeListener; @Px private int thumbSizePixels; @Px private int thumbTouchRadius; - @Px private int cursorPixels; - @ColorInt private int cursorColor; @ColorInt private int thumbColor; - @ColorInt private int thumbColorEdited; private long actualPosition; private long dragPosition; @Px private int thumbHintTextSize; @@ -70,8 +63,6 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView private long dragStartTimeMs; private long dragEndTimeMs; private long maximumSelectableRangeMicros; - private Quality outputQuality; - private long qualityAvailableTimeMs; public VideoThumbnailsRangeSelectorView(final Context context) { super(context); @@ -93,10 +84,7 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.VideoThumbnailsRangeSelectorView, 0, 0); try { thumbSizePixels = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_thumbWidth, 1); - cursorPixels = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_cursorWidth, 1); thumbColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbColor, 0xffff0000); - thumbColorEdited = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbColorEdited, thumbColor); - cursorColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_cursorColor, thumbColor); thumbTouchRadius = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_thumbTouchRadius, 50); thumbHintTextSize = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextSize, 0); thumbHintTextColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextColor, 0xffff0000); @@ -106,9 +94,6 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView } } - chevronLeft = VectorDrawableCompat.create(getResources(), R.drawable.ic_chevron_left_black_8dp, null); - chevronRight = VectorDrawableCompat.create(getResources(), R.drawable.ic_chevron_right_black_8dp, null); - paintGrey.setColor(0x7f000000); paintGrey.setStyle(Paint.Style.FILL_AND_STROKE); paintGrey.setStrokeWidth(1); @@ -135,27 +120,19 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView } if (duration > 0) { - if (externalMinValue != null) { - setMinMax(externalMinValue, getMaxValue(), Thumb.MIN); - externalMinValue = null; - } - if (externalMaxValue != null) { setMinMax(getMinValue(), externalMaxValue, Thumb.MAX); externalMaxValue = null; } - } - if (setMinValue(getMinValue())) { - Log.d(TAG, "Clamped video duration to " + getMaxValue()); - if (onRangeChangeListener != null) { - onRangeChangeListener.onRangeDragEnd(getMinValue(), getMaxValue(), getDuration(), Thumb.MAX); + if (externalMinValue != null) { + setMinMax(externalMinValue, getMaxValue(), Thumb.MIN); + externalMinValue = null; } } if (onRangeChangeListener != null) { onRangeChangeListener.onRangeDragEnd(getMinValue(), getMaxValue(), getDuration(), Thumb.MIN); - setOutputQuality(onRangeChangeListener.getQuality(getClipDuration(), getDuration())); } invalidate(); @@ -183,7 +160,6 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView protected void onDraw(final Canvas canvas) { super.onDraw(canvas); - canvas.translate(getPaddingLeft(), getPaddingTop()); int drawableWidth = getDrawableWidth(); int drawableHeight = getDrawableHeight(); @@ -192,77 +168,56 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView long min = getMinValue(); long max = getMaxValue(); - boolean edited = min != 0 || max != duration; - long drawPosAt = dragThumb == Thumb.POSITION ? dragPosition : actualPosition; 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 tempDrawRect.set(0, 0, left - 1, drawableHeight); canvas.drawRect(tempDrawRect, paintGrey); tempDrawRect.set(right + 1, 0, drawableWidth, drawableHeight); canvas.drawRect(tempDrawRect, paintGrey); - // draw area rectangle - paint.setStyle(Paint.Style.STROKE); - tempDrawRect.set(left, 0, right, drawableHeight); - paint.setColor(edited ? thumbColorEdited : thumbColor); - canvas.drawRect(tempDrawRect, paint); + canvas.restore(); + canvas.translate(getPaddingLeft(), getPaddingTop()); + + int verticalThumbInset = drawableHeight / 4; + int halfThumbWidth = thumbSizePixels / 2; // draw thumb rectangles paint.setStyle(Paint.Style.FILL_AND_STROKE); - tempDrawRect.set(left, 0, left + thumbSizePixels, drawableHeight); - canvas.drawRect(tempDrawRect, paint); - tempDrawRect.set(right - thumbSizePixels, 0, right, drawableHeight); - canvas.drawRect(tempDrawRect, paint); - - int arrowSize = Math.min(drawableHeight, thumbSizePixels * 2); - chevronLeft .setBounds(0, 0, arrowSize, arrowSize); - chevronRight.setBounds(0, 0, arrowSize, arrowSize); - - float dy = (drawableHeight - arrowSize) / 2f; - float arrowPaddingX = (thumbSizePixels - arrowSize) / 2f; - - // draw left thumb chevron - canvas.save(); - canvas.translate(left + arrowPaddingX, dy); - chevronLeft.draw(canvas); - canvas.restore(); - - // draw right thumb chevron - canvas.save(); - canvas.translate(right - thumbSizePixels + arrowPaddingX, dy); - chevronRight.draw(canvas); - canvas.restore(); + 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, drawableHeight, dragThumb, false); + drawTimeHint(canvas, drawableWidth, dragThumb, false); } if (dragEndTimeMs > 0 && (lastDragThumb == Thumb.MIN || lastDragThumb == Thumb.MAX)) { - drawTimeHint(canvas, drawableWidth, drawableHeight, lastDragThumb, true); + drawTimeHint(canvas, drawableWidth, lastDragThumb, true); } - - canvas.save(); - canvas.translate(0, drawableHeight * 2); - drawDurationAndSizeHint(canvas, drawableWidth); - canvas.restore(); } // draw current position marker if (left <= cursor && cursor <= right && dragThumb != Thumb.MIN && dragThumb != Thumb.MAX) { - canvas.translate(cursorPixels / 2, 0); - tempDrawRect.set(cursor, 0, cursor + cursorPixels, drawableHeight); - paint.setColor(cursorColor); - canvas.drawRect(tempDrawRect, paint); + 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, int drawableHeight, Thumb dragThumb, boolean fadeOut) { + 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); @@ -274,11 +229,11 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView timePillRect.set(tempDrawRect.left - leftRightPadding, tempDrawRect.top - topBottomPadding, tempDrawRect.right + leftRightPadding, tempDrawRect.bottom + topBottomPadding); - float halfPillWidth = timePillRect.width() / 2f; + 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); + 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); @@ -288,10 +243,12 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView } else { canvas.translate(Math.max(left, halfPillWidth), 0); } - canvas.translate(0, drawableHeight + halfPillHeight); + + float timePillOffset = timePillRect.height() * -1.5f; + canvas.translate(0, timePillOffset); canvas.scale(scaleIn, scaleIn); - thumbTimeBackgroundPaint.setAlpha(Math.round(alpha * 0.6f)); thumbTimeTextPaint.setAlpha(alpha); + thumbTimeBackgroundPaint.setAlpha(alpha); canvas.translate(leftRightPadding - halfPillWidth, halfPillHeight); canvas.drawRoundRect(timePillRect, halfPillHeight, halfPillHeight, thumbTimeBackgroundPaint); canvas.drawText(timeString, 0, 0, thumbTimeTextPaint); @@ -306,42 +263,6 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView } } - private void drawDurationAndSizeHint(Canvas canvas, int drawableWidth) { - if (outputQuality == null) return; - - canvas.save(); - long microsecondValue = getMaxValue() - getMinValue(); - long seconds = TimeUnit.MICROSECONDS.toSeconds(microsecondValue); - String durationAndSize = String.format(Locale.getDefault(), "%d:%02d • %s", seconds / 60, seconds % 60, MemoryUnitFormat.formatBytes(outputQuality.fileSize, MemoryUnitFormat.MEGA_BYTES, true)); - float topBottomPadding = thumbHintTextSize * 0.5f; - float leftRightPadding = thumbHintTextSize * 0.75f; - - thumbTimeTextPaint.getTextBounds(durationAndSize, 0, durationAndSize.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 = Math.min(ANIMATION_DURATION_MS, System.currentTimeMillis() - qualityAvailableTimeMs); - float animationPosition = animationTime / (float) ANIMATION_DURATION_MS; - float scaleIn = 0.2f * animationPosition + 0.8f; - int alpha = (int) (255 * animationPosition); - - canvas.translate(Math.max(halfPillWidth, Math.min((right + left) / 2f, drawableWidth - halfPillWidth)), - 2 * halfPillHeight); - canvas.scale(scaleIn, scaleIn); - thumbTimeBackgroundPaint.setAlpha(Math.round(alpha * 0.6f)); - thumbTimeTextPaint.setAlpha(alpha); - canvas.translate(leftRightPadding - halfPillWidth, halfPillHeight); - canvas.drawRoundRect(timePillRect, halfPillHeight, halfPillHeight, thumbTimeBackgroundPaint); - canvas.drawText(durationAndSize, 0, 0, thumbTimeTextPaint); - canvas.restore(); - - if (animationTime < ANIMATION_DURATION_MS) { - invalidate(); - } - } - public long getMinValue() { return minValue == null ? 0 : minValue; } @@ -350,22 +271,18 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView return maxValue == null ? getDuration() : maxValue; } - public long getClipDuration() { - return getMaxValue() - getMinValue(); - } - private boolean setMinValue(long minValue) { if (this.minValue == null || this.minValue != minValue) { return setMinMax(minValue, getMaxValue(), Thumb.MIN); - } else{ + } else { return false; } } - public boolean setMaxValue(long maxValue) { + private boolean setMaxValue(long maxValue) { if (this.maxValue == null || this.maxValue != maxValue) { return setMinMax(getMinValue(), maxValue, Thumb.MAX); - } else{ + } else { return false; } } @@ -419,7 +336,7 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView if (actionMasked == MotionEvent.ACTION_MOVE) { boolean changed = false; - long delta = pixelToDuration(event.getX() - xDown); + long delta = pixelToDuration(event.getX() - xDown); switch (dragThumb) { case POSITION: setDragPosition(downCursor + delta); @@ -437,7 +354,6 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView onRangeChangeListener.onPositionDrag(dragPosition); } else { onRangeChangeListener.onRangeDrag(getMinValue(), getMaxValue(), getDuration(), dragThumb); - setOutputQuality(onRangeChangeListener.getQuality(getClipDuration(), getDuration())); } } return true; @@ -449,7 +365,6 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView onRangeChangeListener.onEndPositionDrag(dragPosition); } else { onRangeChangeListener.onRangeDragEnd(getMinValue(), getMaxValue(), getDuration(), dragThumb); - setOutputQuality(onRangeChangeListener.getQuality(getClipDuration(), getDuration())); } lastDragThumb = dragThumb; dragEndTimeMs = System.currentTimeMillis(); @@ -466,20 +381,10 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView return true; } - private void setOutputQuality(@Nullable Quality outputQuality) { - if (!Objects.equals(this.outputQuality, outputQuality)) { - if (this.outputQuality == null) { - qualityAvailableTimeMs = System.currentTimeMillis(); - } - this.outputQuality = outputQuality; - invalidate(); - } - } - private @Nullable Thumb closestThumb(@Px float x) { - float midPoint = (right + left) / 2f; - Thumb possibleThumb = x < midPoint ? Thumb.MIN : Thumb.MAX; - int possibleThumbX = x < midPoint ? left : right; + 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; @@ -503,7 +408,6 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView public void setRange(long minValue, long maxValue) { if (getDuration() > 0) { setMinMax(minValue, maxValue, Thumb.MIN); - setMinMax(minValue, maxValue, Thumb.MAX); } else { externalMinValue = minValue; externalMaxValue = maxValue; @@ -529,34 +433,5 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView void onRangeDrag(long minValue, long maxValue, long duration, Thumb thumb); void onRangeDragEnd(long minValue, long maxValue, long duration, Thumb thumb); - - @Nullable Quality getQuality(long clipDurationUs, long totalDurationUs); - } - - public static final class Quality { - private final long fileSize; - private final int qualityRange; - - public Quality(long fileSize, int qualityRange) { - this.fileSize = fileSize; - this.qualityRange = qualityRange; - } - - @Override public boolean equals(Object o) { - if (!(o instanceof Quality)) { - return false; - } - - final Quality quality = (Quality) o; - - return fileSize == quality.fileSize && - qualityRange == quality.qualityRange; - } - - @Override public int hashCode() { - int result = (int) (fileSize ^ (fileSize >>> 32)); - result = 31 * result + qualityRange; - return result; - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java index 2e5fe1a1d1..ca6e2429b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java @@ -4,6 +4,7 @@ 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.os.AsyncTask; @@ -15,6 +16,7 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.video.interfaces.MediaInput; import java.io.IOException; @@ -26,12 +28,12 @@ import java.util.List; @RequiresApi(api = 23) public class VideoThumbnailsView extends View { - private static final String TAG = Log.tag(VideoThumbnailsView.class); + private static final String TAG = Log.tag(VideoThumbnailsView.class); + private static final int CORNER_RADIUS = ViewUtil.dpToPx(8); - private MediaInput input; - private volatile ArrayList thumbnails; + private MediaInput input; + private volatile ArrayList thumbnails; private AsyncTask thumbnailsTask; - private OnDurationListener durationListener; private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final RectF tempRect = new RectF(); @@ -39,6 +41,8 @@ public class VideoThumbnailsView extends View { private final Rect tempDrawRect = new Rect(); private long duration = 0; + protected final Path clippingPath = new Path(); + public VideoThumbnailsView(final Context context) { super(context); } @@ -52,6 +56,10 @@ public class VideoThumbnailsView extends View { } public void setInput(@NonNull MediaInput input) { + if (this.input != null && input.hasSameInput(this.input)) { + return; + } + this.input = input; this.thumbnails = null; if (thumbnailsTask != null) { @@ -88,7 +96,15 @@ public class VideoThumbnailsView extends View { return; } - tempDrawRect.set(getPaddingLeft(), getPaddingTop(), getWidth() - getPaddingRight(), getHeight() - getPaddingBottom()); + 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); @@ -116,6 +132,9 @@ public class VideoThumbnailsView extends View { 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; @@ -139,17 +158,12 @@ public class VideoThumbnailsView extends View { canvas.restore(); } } + + canvas.restore(); } } - public void setDurationListener(OnDurationListener durationListener) { - this.durationListener = durationListener; - } - - private void setDuration(long duration) { - if (durationListener != null) { - durationListener.onDurationKnown(duration); - } + public void setDuration(long duration) { if (this.duration != duration) { this.duration = duration; afterDurationChange(duration); @@ -159,7 +173,7 @@ public class VideoThumbnailsView extends View { protected void afterDurationChange(long duration) { } - protected long getDuration() { + public long getDuration() { return duration; } diff --git a/app/src/main/res/drawable-v21/media_gallery_button_background.xml b/app/src/main/res/drawable-v21/media_gallery_button_background.xml index 472e952f18..58ab33f9de 100644 --- a/app/src/main/res/drawable-v21/media_gallery_button_background.xml +++ b/app/src/main/res/drawable-v21/media_gallery_button_background.xml @@ -2,11 +2,11 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_hq_24.xml b/app/src/main/res/drawable/ic_hq_24.xml deleted file mode 100644 index 560d38f140..0000000000 --- a/app/src/main/res/drawable/ic_hq_24.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_sq_24.xml b/app/src/main/res/drawable/ic_sq_24.xml deleted file mode 100644 index 876bbc1036..0000000000 --- a/app/src/main/res/drawable/ic_sq_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/media_gallery_button_background.xml b/app/src/main/res/drawable/media_gallery_button_background.xml index 51f3b8bc4f..38d4b5c5aa 100644 --- a/app/src/main/res/drawable/media_gallery_button_background.xml +++ b/app/src/main/res/drawable/media_gallery_button_background.xml @@ -2,9 +2,9 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_rectangle_container_primary_32.xml b/app/src/main/res/drawable/rounded_rectangle_container_primary_32.xml new file mode 100644 index 0000000000..334c882020 --- /dev/null +++ b/app/src/main/res/drawable/rounded_rectangle_container_primary_32.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/symbol_quality_high_24.xml b/app/src/main/res/drawable/symbol_quality_high_24.xml new file mode 100644 index 0000000000..fb678fb6a8 --- /dev/null +++ b/app/src/main/res/drawable/symbol_quality_high_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/symbol_quality_high_slash_24.xml b/app/src/main/res/drawable/symbol_quality_high_slash_24.xml new file mode 100644 index 0000000000..23a244a860 --- /dev/null +++ b/app/src/main/res/drawable/symbol_quality_high_slash_24.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/layout/mediasend_video_fragment.xml b/app/src/main/res/layout/mediasend_video_fragment.xml index f72eb6bdc4..6e29be3dda 100644 --- a/app/src/main/res/layout/mediasend_video_fragment.xml +++ b/app/src/main/res/layout/mediasend_video_fragment.xml @@ -10,7 +10,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/v2_media_add_message_dialog_fragment_content.xml b/app/src/main/res/layout/v2_media_add_message_dialog_fragment_content.xml index d36265300e..f68caddb76 100644 --- a/app/src/main/res/layout/v2_media_add_message_dialog_fragment_content.xml +++ b/app/src/main/res/layout/v2_media_add_message_dialog_fragment_content.xml @@ -9,7 +9,7 @@ android:id="@+id/background_holder" android:layout_width="match_parent" android:layout_height="0dp" - android:background="@color/core_grey_95" + android:background="@color/signal_colorSurface1" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toTopOf="@id/input_barrier" /> @@ -23,7 +23,7 @@ @@ -59,6 +60,7 @@ android:minHeight="36dp" android:paddingEnd="10dp" android:textAppearance="@style/Signal.Text.Body" + android:textColor="@color/signal_colorOnSurface" app:layout_constraintBottom_toTopOf="@id/emoji_drawer_stub" app:layout_constraintEnd_toStartOf="@id/add_a_message_limit" app:layout_constraintStart_toEndOf="@id/emoji_toggle" @@ -72,7 +74,7 @@ android:layout_marginEnd="6dp" android:paddingEnd="12dp" android:textAppearance="@style/Signal.Text.BodySmall" - android:textColor="@color/signal_colorOnSurfaceVariant" + android:textColor="@color/signal_colorOnSurface" android:visibility="gone" app:layout_constraintBottom_toBottomOf="@id/add_a_message_input" app:layout_constraintEnd_toStartOf="@id/confirm_button" diff --git a/app/src/main/res/layout/v2_media_review_fragment.xml b/app/src/main/res/layout/v2_media_review_fragment.xml index e2aac7b2c7..8a6d3b1742 100644 --- a/app/src/main/res/layout/v2_media_review_fragment.xml +++ b/app/src/main/res/layout/v2_media_review_fragment.xml @@ -1,10 +1,10 @@ + android:layout_height="match_parent" + tools:viewBindingIgnore="true"> + android:layout_height="match_parent" + android:clipChildren="false"> + + + + @@ -107,94 +137,74 @@ android:layout_width="40dp" android:layout_height="40dp" android:layout_gravity="center" - android:background="@drawable/media_gallery_button_background" android:scaleType="centerInside" - app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle" app:srcCompat="@drawable/ic_view_infinite_24" - app:tint="@color/core_white" /> + app:tint="@color/signal_colorOnSurface" /> + app:tint="@color/signal_colorOnSurface" /> - - - - - - - - + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintStart_toStartOf="parent" + app:thumbColor="@color/signal_light_colorOnPrimary" + app:thumbColorEdited="#ff0" + app:thumbHintBackgroundColor="@color/signal_dark_colorPrimaryContainer" + app:thumbHintTextColor="@color/signal_light_colorOnPrimary" + app:thumbHintTextSize="14sp" + app:thumbTouchRadius="24dp" + app:thumbWidth="6dp" + tools:visibility="visible" /> + + + + + tools:visibility="visible" /> + app:srcCompat="@drawable/ic_send_lock_24" + app:tint="@color/signal_colorOnSurface" /> @@ -309,8 +311,8 @@ android:alpha="0" android:background="@color/transparent_black_60" android:visibility="gone" - tools:alpha="1" - tools:visibility="visible"> + tools:alpha="0" + tools:visibility="gone"> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/video_editor_hud.xml b/app/src/main/res/layout/video_editor_hud.xml index 5c474e3b39..623733965a 100644 --- a/app/src/main/res/layout/video_editor_hud.xml +++ b/app/src/main/res/layout/video_editor_hud.xml @@ -1,66 +1,37 @@ + tools:parentTag="android.widget.FrameLayout" + tools:viewBindingIgnore="true"> - + - - - + android:contentDescription="@string/ThumbnailView_Play_video_description" + android:scaleType="fitCenter" + app:srcCompat="@drawable/exo_icon_play" /> - - - - - + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 8f5801e713..1c1bb2c3e9 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -280,8 +280,6 @@ - - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index bc205550ee..89a224b506 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -228,4 +228,8 @@ 32dp 24dp + + 44dp + 1dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f3c26a620..db2bcfc9a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4402,11 +4402,16 @@ Group description + Standard + Faster, less data + High + Slower, more data - Photo quality + + Media quality Invite your friends @@ -4882,6 +4887,22 @@ One or more items were too large One or more items were invalid Too many items selected + + Video set to view once + + Photo sent to view once + + Video set to high quality + + Video set to standard quality + + Photo set to high quality + + Photo set to standard quality + + %d items set to high quality + + %d items set to standard quality Cancel Draw diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 4d9e1f0dd8..1e254eeca9 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1327,6 +1327,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + diff --git a/video/app/build.gradle.kts b/video/app/build.gradle.kts index 39b8101922..51e84b782c 100644 --- a/video/app/build.gradle.kts +++ b/video/app/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { implementation(project(":video")) implementation(project(":core-util")) implementation("androidx.work:work-runtime-ktx:2.9.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation(libs.androidx.compose.ui.tooling.core) implementation(libs.androidx.compose.ui.test.manifest) androidTestImplementation(testLibs.junit.junit) diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/Constants.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/Constants.kt index 35374abd75..2a46e63180 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/Constants.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/Constants.kt @@ -9,8 +9,8 @@ package org.thoughtcrime.video.app.transcode * A dumping ground for constants that should be referenced across the sample app. */ internal const val MIN_VIDEO_MEGABITRATE = 0.5f -internal const val DEFAULT_VIDEO_MEGABITRATE = 2.5f -internal const val MAX_VIDEO_MEGABITRATE = 5f +internal const val MAX_VIDEO_MEGABITRATE = 4f +internal val OPTIONS_AUDIO_KILOBITRATES = listOf(64, 96, 128, 160, 192) enum class VideoResolution(val longEdge: Int, val shortEdge: Int) { SD(854, 480), diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt index 21e015a2e2..76137f651c 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt @@ -22,11 +22,15 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -43,6 +47,7 @@ import org.thoughtcrime.video.app.ui.theme.SignalTheme class TranscodeTestActivity : AppCompatActivity() { private val TAG = "TranscodeTestActivity" private val viewModel: TranscodeTestViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.initialize(this) @@ -60,28 +65,19 @@ class TranscodeTestActivity : AppCompatActivity() { setContent { SignalTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - val videoUris = viewModel.selectedVideos - val outputDir = viewModel.outputDirectory val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsState(emptyList()) if (transcodingJobs.value.isNotEmpty()) { TranscodingJobProgress(transcodingJobs = transcodingJobs.value, resetButtonOnClick = { viewModel.reset() }) - } else if (videoUris.isEmpty()) { + } else if (viewModel.selectedVideos.isEmpty()) { SelectInput { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) } - } else if (outputDir == null) { + } else if (viewModel.outputDirectory == null) { SelectOutput { outputDirRequest.launch(null) } } else { ConfigureEncodingParameters( - videoUris = videoUris, - onAutoSettingsCheckChanged = { viewModel.useAutoTranscodingSettings = it }, - onRadioButtonSelected = { viewModel.videoResolution = it }, - onSliderValueChanged = { viewModel.videoMegaBitrate = it }, - onFastStartSettingCheckChanged = { viewModel.enableFastStart = it }, - onSequentialSettingCheckChanged = { viewModel.forceSequentialQueueProcessing = it }, - buttonClickListener = { - viewModel.transcode() - viewModel.selectedVideos = emptyList() - viewModel.resetOutputDirectory() - } + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + viewModel = viewModel ) } } diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt index daa1933b56..19309c8894 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt @@ -16,6 +16,7 @@ import androidx.work.WorkQuery import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.signal.core.util.readToList +import org.thoughtcrime.securesms.video.TranscodingPreset import java.util.UUID import kotlin.math.absoluteValue import kotlin.random.Random @@ -27,7 +28,13 @@ class TranscodeTestRepository(context: Context) { private val workManager = WorkManager.getInstance(context) private val usedNotificationIds = emptySet() - fun transcode(selectedVideos: List, outputDirectory: Uri, forceSequentialProcessing: Boolean, customTranscodingOptions: CustomTranscodingOptions?): Map { + private fun transcode(selectedVideos: List, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset? = null, customTranscodingOptions: CustomTranscodingOptions? = null): Map { + if (customTranscodingOptions == null && transcodingPreset == null) { + throw IllegalArgumentException("Must define either custom options or transcoding preset!") + } else if (customTranscodingOptions != null && transcodingPreset != null) { + throw IllegalArgumentException("Cannot define both custom options and transcoding preset!") + } + if (selectedVideos.isEmpty()) { return emptyMap() } @@ -42,11 +49,15 @@ class TranscodeTestRepository(context: Context) { .putString(TranscodeWorker.KEY_OUTPUT_URI, outputDirectory.toString()) .putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId) - if (customTranscodingOptions != null) { + if (transcodingPreset != null) { + inputData.putString(TranscodeWorker.KEY_TRANSCODING_PRESET_NAME, transcodingPreset.name) + } else if (customTranscodingOptions != null) { inputData.putInt(TranscodeWorker.KEY_LONG_EDGE, customTranscodingOptions.videoResolution.longEdge) inputData.putInt(TranscodeWorker.KEY_SHORT_EDGE, customTranscodingOptions.videoResolution.shortEdge) - inputData.putInt(TranscodeWorker.KEY_BIT_RATE, customTranscodingOptions.bitrate) + inputData.putInt(TranscodeWorker.KEY_VIDEO_BIT_RATE, customTranscodingOptions.videoBitrate) + inputData.putInt(TranscodeWorker.KEY_AUDIO_BIT_RATE, customTranscodingOptions.audioBitrate) inputData.putBoolean(TranscodeWorker.KEY_ENABLE_FASTSTART, customTranscodingOptions.enableFastStart) + inputData.putBoolean(TranscodeWorker.KEY_ENABLE_AUDIO_REMUX, customTranscodingOptions.enableAudioRemux) } val transcodeRequest = OneTimeWorkRequestBuilder() @@ -69,6 +80,14 @@ class TranscodeTestRepository(context: Context) { return idsToUris } + fun transcodeWithCustomOptions(selectedVideos: List, outputDirectory: Uri, forceSequentialProcessing: Boolean, customTranscodingOptions: CustomTranscodingOptions?): Map { + return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, customTranscodingOptions = customTranscodingOptions) + } + + fun transcodeWithPresetOptions(selectedVideos: List, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset): Map { + return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, transcodingPreset) + } + fun getTranscodingJobsAsFlow(jobIds: List): Flow> { if (jobIds.isEmpty()) { return emptyFlow() @@ -117,7 +136,7 @@ class TranscodeTestRepository(context: Context) { private data class FileMetadata(val documentId: String, val label: String, val size: Long) - data class CustomTranscodingOptions(val videoResolution: VideoResolution, val bitrate: Int, val enableFastStart: Boolean) + data class CustomTranscodingOptions(val videoResolution: VideoResolution, val videoBitrate: Int, val audioBitrate: Int, val enableFastStart: Boolean, val enableAudioRemux: Boolean) companion object { private const val TAG = "TranscodingTestRepository" diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt index f8127166c6..0829da2e68 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt @@ -9,11 +9,15 @@ import android.content.Context import android.net.Uri import android.widget.Toast import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.work.WorkInfo import kotlinx.coroutines.flow.Flow +import org.thoughtcrime.securesms.video.TranscodingPreset +import org.thoughtcrime.securesms.video.TranscodingQuality import java.util.UUID import kotlin.math.roundToInt @@ -25,14 +29,20 @@ class TranscodeTestViewModel : ViewModel() { private var backPressedRunnable = {} private var transcodingJobs: Map = emptyMap() + var transcodingPreset by mutableStateOf(TranscodingPreset.LEVEL_2) + private set + var outputDirectory: Uri? by mutableStateOf(null) private set + var selectedVideos: List by mutableStateOf(emptyList()) - var videoMegaBitrate = DEFAULT_VIDEO_MEGABITRATE - var videoResolution = VideoResolution.HD - var useAutoTranscodingSettings = true - var enableFastStart = true - var forceSequentialQueueProcessing = false + var videoMegaBitrate by mutableFloatStateOf(calculateVideoMegaBitrateFromPreset(transcodingPreset)) + var videoResolution by mutableStateOf(convertPresetToVideoResolution(transcodingPreset)) + var audioKiloBitrate by mutableIntStateOf(calculateAudioKiloBitrateFromPreset(transcodingPreset)) + var useAutoTranscodingSettings by mutableStateOf(true) + var enableFastStart by mutableStateOf(true) + var enableAudioRemux by mutableStateOf(true) + var forceSequentialQueueProcessing by mutableStateOf(false) fun initialize(context: Context) { repository = TranscodeTestRepository(context) @@ -41,13 +51,36 @@ class TranscodeTestViewModel : ViewModel() { fun transcode() { val output = outputDirectory ?: throw IllegalStateException("No output directory selected!") - if (useAutoTranscodingSettings) { - transcodingJobs = repository.transcode(selectedVideos, output, forceSequentialQueueProcessing, null) + transcodingJobs = if (useAutoTranscodingSettings) { + repository.transcodeWithPresetOptions( + selectedVideos, + output, + forceSequentialQueueProcessing, + transcodingPreset + ) } else { - transcodingJobs = repository.transcode(selectedVideos, output, forceSequentialQueueProcessing, TranscodeTestRepository.CustomTranscodingOptions(videoResolution, (videoMegaBitrate * MEGABIT).roundToInt(), enableFastStart)) + repository.transcodeWithCustomOptions( + selectedVideos, + output, + forceSequentialQueueProcessing, + TranscodeTestRepository.CustomTranscodingOptions( + videoResolution, + (videoMegaBitrate * MEGABIT).roundToInt(), + audioKiloBitrate * KILOBIT, + enableAudioRemux, + enableFastStart + ) + ) } } + fun updateTranscodingPreset(preset: TranscodingPreset) { + transcodingPreset = preset + videoResolution = convertPresetToVideoResolution(preset) + videoMegaBitrate = calculateVideoMegaBitrateFromPreset(preset) + audioKiloBitrate = calculateAudioKiloBitrateFromPreset(preset) + } + fun getTranscodingJobsAsState(): Flow> { return repository.getTranscodingJobsAsFlow(transcodingJobs.keys.toList()) } @@ -57,17 +90,13 @@ class TranscodeTestViewModel : ViewModel() { repository.cleanFailedTranscodes(context, folderUri) } - fun getUriFromJobId(jobId: UUID): Uri? { - return transcodingJobs[jobId] - } - fun reset() { cancelAllTranscodes() resetOutputDirectory() selectedVideos = emptyList() } - fun cancelAllTranscodes() { + private fun cancelAllTranscodes() { repository.cancelAllTranscodes() transcodingJobs = emptyMap() } @@ -78,5 +107,24 @@ class TranscodeTestViewModel : ViewModel() { companion object { private const val MEGABIT = 1000000 + private const val KILOBIT = 1000 + + @JvmStatic + private fun calculateVideoMegaBitrateFromPreset(preset: TranscodingPreset): Float { + val quality = TranscodingQuality.createFromPreset(preset, -1) + return quality.targetVideoBitRate.toFloat() / MEGABIT + } + + @JvmStatic + private fun calculateAudioKiloBitrateFromPreset(preset: TranscodingPreset): Int { + val quality = TranscodingQuality.createFromPreset(preset, -1) + return quality.targetAudioBitRate / KILOBIT + } + + @JvmStatic + private fun convertPresetToVideoResolution(preset: TranscodingPreset) = when (preset) { + TranscodingPreset.LEVEL_3 -> VideoResolution.HD + else -> VideoResolution.SD + } } } diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt index 0b4136d25a..10b3e078a4 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt @@ -24,6 +24,7 @@ import androidx.work.WorkerParameters import org.signal.core.util.getLength import org.signal.core.util.readLength import org.thoughtcrime.securesms.video.StreamingTranscoder +import org.thoughtcrime.securesms.video.TranscodingPreset import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants @@ -41,6 +42,12 @@ class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker( @UnstableApi override suspend fun doWork(): Result { val logPrefix = "[Job ${id.toString().takeLast(4)}]" + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Log.w(TAG, "$logPrefix Transcoder is only supported on API 26+!") + return Result.failure() + } + val notificationId = inputData.getInt(KEY_NOTIFICATION_ID, -1) if (notificationId < 0) { Log.w(TAG, "$logPrefix Notification ID was null!") @@ -60,8 +67,11 @@ class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker( } val postProcessForFastStart = inputData.getBoolean(KEY_ENABLE_FASTSTART, true) + val transcodingPreset = inputData.getString(KEY_TRANSCODING_PRESET_NAME) val resolution = inputData.getInt(KEY_SHORT_EDGE, -1) - val desiredBitrate = inputData.getInt(KEY_BIT_RATE, -1) + val videoBitrate = inputData.getInt(KEY_VIDEO_BIT_RATE, -1) + val audioBitrate = inputData.getInt(KEY_AUDIO_BIT_RATE, -1) + val audioRemux = inputData.getBoolean(KEY_ENABLE_AUDIO_REMUX, true) val input = DocumentFile.fromSingleUri(applicationContext, Uri.parse(inputUri))?.name if (input == null) { @@ -80,15 +90,14 @@ class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker( } val datasource = WorkerMediaDataSource(applicationContext, Uri.parse(inputUri)) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - Log.w(TAG, "$logPrefix Transcoder is only supported on API 26+!") - return Result.failure() - } - val transcoder = if (resolution > 0 && desiredBitrate > 0) { - StreamingTranscoder(datasource, null, desiredBitrate, resolution, false) + val transcoder = if (resolution > 0 && videoBitrate > 0) { + Log.d(TAG, "$logPrefix Initializing StreamingTranscoder with custom parameters: B:V=$videoBitrate, B:A=$audioBitrate, res=$resolution, audioRemux=$audioRemux") + StreamingTranscoder.createManuallyForTesting(datasource, null, videoBitrate, audioBitrate, resolution, audioRemux) + } else if (transcodingPreset != null) { + StreamingTranscoder(datasource, null, TranscodingPreset.valueOf(transcodingPreset), DEFAULT_FILE_SIZE_LIMIT, audioRemux) } else { - StreamingTranscoder(datasource, null, DEFAULT_FILE_SIZE_LIMIT, true) + throw IllegalArgumentException("Improper input data! No TranscodingPreset defined, or invalid manual parameters!") } setForeground(createForegroundInfo(-1, notificationId)) @@ -213,13 +222,16 @@ class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker( private const val TAG = "TranscodeWorker" private const val OUTPUT_FILE_EXTENSION = ".mp4" private const val TEMP_FILE_EXTENSION = ".tmp" - private const val DEFAULT_FILE_SIZE_LIMIT: Long = 50 * 1024 * 1024 + private const val DEFAULT_FILE_SIZE_LIMIT: Long = 100 * 1024 * 1024 const val KEY_INPUT_URI = "input_uri" const val KEY_OUTPUT_URI = "output_uri" + const val KEY_TRANSCODING_PRESET_NAME = "transcoding_quality_preset" const val KEY_PROGRESS = "progress" const val KEY_LONG_EDGE = "resolution_long_edge" const val KEY_SHORT_EDGE = "resolution_short_edge" - const val KEY_BIT_RATE = "video_bit_rate" + const val KEY_VIDEO_BIT_RATE = "video_bit_rate" + const val KEY_AUDIO_BIT_RATE = "audio_bit_rate" + const val KEY_ENABLE_AUDIO_REMUX = "audio_remux" const val KEY_ENABLE_FASTSTART = "video_enable_faststart" const val KEY_NOTIFICATION_ID = "notification_id" } diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt index 4b6d7cc41d..9ec0351c4d 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt @@ -20,11 +20,6 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role @@ -35,179 +30,272 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.thoughtcrime.video.app.transcode.DEFAULT_VIDEO_MEGABITRATE +import androidx.lifecycle.viewmodel.compose.viewModel +import org.thoughtcrime.securesms.video.TranscodingPreset import org.thoughtcrime.video.app.transcode.MAX_VIDEO_MEGABITRATE import org.thoughtcrime.video.app.transcode.MIN_VIDEO_MEGABITRATE +import org.thoughtcrime.video.app.transcode.OPTIONS_AUDIO_KILOBITRATES +import org.thoughtcrime.video.app.transcode.TranscodeTestViewModel import org.thoughtcrime.video.app.transcode.VideoResolution import org.thoughtcrime.video.app.ui.composables.LabeledButton +import kotlin.math.roundToInt /** * A view that shows the queue of video URIs to encode, and allows you to change the encoding options. */ @Composable fun ConfigureEncodingParameters( - videoUris: List, - onAutoSettingsCheckChanged: (Boolean) -> Unit, - onRadioButtonSelected: (VideoResolution) -> Unit, - onSliderValueChanged: (Float) -> Unit, - onFastStartSettingCheckChanged: (Boolean) -> Unit, - onSequentialSettingCheckChanged: (Boolean) -> Unit, - buttonClickListener: () -> Unit, modifier: Modifier = Modifier, - initialSettingsAutoSelected: Boolean = true + viewModel: TranscodeTestViewModel = viewModel() ) { - var sliderPosition by remember { mutableFloatStateOf(DEFAULT_VIDEO_MEGABITRATE) } - var selectedResolution by remember { mutableStateOf(VideoResolution.HD) } - val autoSettingsChecked = remember { mutableStateOf(initialSettingsAutoSelected) } - val fastStartChecked = remember { mutableStateOf(true) } - val sequentialProcessingChecked = remember { mutableStateOf(false) } Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier.padding(16.dp) + modifier = modifier ) { Text( text = "Selected videos:", modifier = Modifier + .padding(horizontal = 8.dp) .align(Alignment.Start) - .padding(16.dp) ) - videoUris.forEach { + viewModel.selectedVideos.forEach { Text( text = it.toString(), fontSize = 8.sp, fontFamily = FontFamily.Monospace, modifier = Modifier + .padding(horizontal = 8.dp) .align(Alignment.Start) - .padding(horizontal = 16.dp) ) } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(horizontal = 8.dp) .fillMaxWidth() ) { Checkbox( - checked = sequentialProcessingChecked.value, - onCheckedChange = { isChecked -> - sequentialProcessingChecked.value = isChecked - onSequentialSettingCheckChanged(isChecked) - } + checked = viewModel.forceSequentialQueueProcessing, + onCheckedChange = { viewModel.forceSequentialQueueProcessing = it } ) Text(text = "Force Sequential Queue Processing", style = MaterialTheme.typography.bodySmall) } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(vertical = 8.dp, horizontal = 8.dp) .fillMaxWidth() ) { Checkbox( - checked = autoSettingsChecked.value, - onCheckedChange = { isChecked -> - autoSettingsChecked.value = isChecked - onAutoSettingsCheckChanged(isChecked) - } + checked = viewModel.useAutoTranscodingSettings, + onCheckedChange = { viewModel.useAutoTranscodingSettings = it } ) Text( text = "Match Signal App Transcoding Settings", style = MaterialTheme.typography.bodySmall ) } - if (!autoSettingsChecked.value) { - Row( - modifier = Modifier - .padding(vertical = 16.dp) - .fillMaxWidth() - .selectableGroup() - ) { - VideoResolution.values().forEach { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .selectable( - selected = selectedResolution == it, - onClick = { - selectedResolution = it - onRadioButtonSelected(it) - }, - role = Role.RadioButton - ) - ) { - RadioButton( - selected = selectedResolution == it, - onClick = null, - modifier = Modifier.semantics { contentDescription = it.getContentDescription() } - ) - Text( - text = "${it.shortEdge}p", - textAlign = TextAlign.Center, - modifier = Modifier.padding(start = 16.dp) - ) - } - } - } - Slider( - value = sliderPosition, - onValueChange = { - sliderPosition = it - onSliderValueChanged(it) - }, - colors = SliderDefaults.colors( - thumbColor = MaterialTheme.colorScheme.secondary, - activeTrackColor = MaterialTheme.colorScheme.secondary, - inactiveTrackColor = MaterialTheme.colorScheme.secondaryContainer - ), - valueRange = MIN_VIDEO_MEGABITRATE..MAX_VIDEO_MEGABITRATE, - modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp) + if (viewModel.useAutoTranscodingSettings) { + PresetPicker( + viewModel.transcodingPreset, + viewModel::updateTranscodingPreset, + modifier = Modifier.padding(vertical = 16.dp) + ) + } else { + CustomSettings( + selectedResolution = viewModel.videoResolution, + onResolutionSelected = { viewModel.videoResolution = it }, + fastStartChecked = viewModel.enableFastStart, + onFastStartSettingCheckChanged = { viewModel.enableFastStart = it }, + audioRemuxChecked = viewModel.enableAudioRemux, + onAudioRemuxCheckChanged = { viewModel.enableAudioRemux = it }, + videoSliderPosition = viewModel.videoMegaBitrate, + updateVideoSliderPosition = { viewModel.videoMegaBitrate = it }, + audioSliderPosition = viewModel.audioKiloBitrate, + updateAudioSliderPosition = { viewModel.audioKiloBitrate = it.roundToInt() }, + modifier = Modifier.padding(vertical = 16.dp) ) - Text(text = String.format("%.1f Mbit/s", sliderPosition)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 8.dp) - .fillMaxWidth() - ) { - Checkbox( - checked = fastStartChecked.value, - onCheckedChange = { isChecked -> - fastStartChecked.value = isChecked - onFastStartSettingCheckChanged(isChecked) - } - ) - Text(text = "Enable Mp4San Postprocessing", style = MaterialTheme.typography.bodySmall) - } } - LabeledButton(buttonLabel = "Transcode", onClick = buttonClickListener, modifier = Modifier.padding(vertical = 8.dp)) + LabeledButton( + buttonLabel = "Transcode", + onClick = { + viewModel.transcode() + viewModel.selectedVideos = emptyList() + viewModel.resetOutputDirectory() + }, + modifier = Modifier.padding(vertical = 8.dp) + ) } } -@Preview @Composable -private fun ConfigurationScreenPreviewChecked() { - ConfigureEncodingParameters( - videoUris = listOf(Uri.parse("content://1"), Uri.parse("content://2")), - onAutoSettingsCheckChanged = {}, - onRadioButtonSelected = {}, - onSliderValueChanged = {}, - onFastStartSettingCheckChanged = {}, - onSequentialSettingCheckChanged = {}, - buttonClickListener = {} - ) +private fun PresetPicker( + selectedTranscodingPreset: TranscodingPreset, + onPresetSelected: (TranscodingPreset) -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = modifier + .fillMaxWidth() + .selectableGroup() + ) { + TranscodingPreset.values().forEach { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .selectable( + selected = selectedTranscodingPreset == it, + onClick = { + onPresetSelected(it) + }, + role = Role.RadioButton + ) + ) { + RadioButton( + selected = selectedTranscodingPreset == it, + onClick = null, + modifier = Modifier.semantics { contentDescription = it.name } + ) + Text( + text = it.name, + textAlign = TextAlign.Center + ) + } + } + } } -@Preview +@Composable +private fun CustomSettings( + selectedResolution: VideoResolution, + onResolutionSelected: (VideoResolution) -> Unit, + fastStartChecked: Boolean, + onFastStartSettingCheckChanged: (Boolean) -> Unit, + audioRemuxChecked: Boolean, + onAudioRemuxCheckChanged: (Boolean) -> Unit, + videoSliderPosition: Float, + updateVideoSliderPosition: (Float) -> Unit, + audioSliderPosition: Int, + updateAudioSliderPosition: (Float) -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = modifier + .fillMaxWidth() + .selectableGroup() + ) { + VideoResolution.values().forEach { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .selectable( + selected = selectedResolution == it, + onClick = { onResolutionSelected(it) }, + role = Role.RadioButton + ) + .padding(start = 16.dp) + ) { + RadioButton( + selected = selectedResolution == it, + onClick = null, + modifier = Modifier.semantics { contentDescription = it.getContentDescription() } + ) + Text( + text = "${it.shortEdge}p", + textAlign = TextAlign.Center + ) + } + } + } + VideoBitrateSlider(videoSliderPosition, updateVideoSliderPosition) + AudioBitrateSlider(audioSliderPosition, updateAudioSliderPosition) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 8.dp) + .fillMaxWidth() + ) { + Checkbox( + checked = audioRemuxChecked, + onCheckedChange = { onAudioRemuxCheckChanged(it) } + ) + Text(text = "Allow audio remuxing", style = MaterialTheme.typography.bodySmall) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 8.dp) + .fillMaxWidth() + ) { + Checkbox( + checked = fastStartChecked, + onCheckedChange = { onFastStartSettingCheckChanged(it) } + ) + Text(text = "Perform Mp4San Postprocessing", style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +private fun VideoBitrateSlider( + videoSliderPosition: Float, + updateSliderPosition: (Float) -> Unit, + modifier: Modifier = Modifier +) { + Slider( + value = videoSliderPosition, + onValueChange = updateSliderPosition, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.secondary, + activeTrackColor = MaterialTheme.colorScheme.secondary, + inactiveTrackColor = MaterialTheme.colorScheme.secondaryContainer + ), + valueRange = MIN_VIDEO_MEGABITRATE..MAX_VIDEO_MEGABITRATE, + modifier = modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) + Text(text = String.format("Video: %.2f Mbit/s", videoSliderPosition)) +} + +@Composable +private fun AudioBitrateSlider( + audioSliderPosition: Int, + updateSliderPosition: (Float) -> Unit, + modifier: Modifier = Modifier +) { + val minValue = OPTIONS_AUDIO_KILOBITRATES.first().toFloat() + val maxValue = OPTIONS_AUDIO_KILOBITRATES.last().toFloat() + val steps = OPTIONS_AUDIO_KILOBITRATES.size - 2 + + Slider( + value = audioSliderPosition.toFloat(), + onValueChange = updateSliderPosition, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.secondary, + activeTrackColor = MaterialTheme.colorScheme.secondary, + inactiveTrackColor = MaterialTheme.colorScheme.secondaryContainer + ), + valueRange = minValue..maxValue, + steps = steps, + modifier = modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) + Text(text = String.format("Audio: %d Kbit/s", audioSliderPosition)) +} + +@Preview(showBackground = true) +@Composable +private fun ConfigurationScreenPreviewChecked() { + val vm: TranscodeTestViewModel = viewModel() + vm.selectedVideos = listOf(Uri.parse("content://1"), Uri.parse("content://2")) + vm.forceSequentialQueueProcessing = true + ConfigureEncodingParameters() +} + +@Preview(showBackground = true) @Composable private fun ConfigurationScreenPreviewUnchecked() { - ConfigureEncodingParameters( - videoUris = listOf(Uri.parse("content://1"), Uri.parse("content://2")), - onAutoSettingsCheckChanged = {}, - onRadioButtonSelected = {}, - onSliderValueChanged = {}, - onFastStartSettingCheckChanged = {}, - onSequentialSettingCheckChanged = {}, - buttonClickListener = {}, - initialSettingsAutoSelected = false - ) + val vm: TranscodeTestViewModel = viewModel() + vm.selectedVideos = listOf(Uri.parse("content://1"), Uri.parse("content://2")) + vm.useAutoTranscodingSettings = false + ConfigureEncodingParameters() } diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java b/video/lib/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java index 99d27fd8a5..90424a2ec2 100644 --- a/video/lib/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java @@ -6,6 +6,7 @@ import android.media.MediaMetadataRetriever; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; import com.google.common.io.CountingOutputStream; @@ -13,9 +14,8 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.video.exceptions.VideoSizeException; import org.thoughtcrime.securesms.video.exceptions.VideoSourceException; import org.thoughtcrime.securesms.video.interfaces.TranscoderCancelationSignal; -import org.thoughtcrime.securesms.video.videoconverter.exceptions.EncodingException; import org.thoughtcrime.securesms.video.videoconverter.MediaConverter; -import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants; +import org.thoughtcrime.securesms.video.videoconverter.exceptions.EncodingException; import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput; import java.io.FilterOutputStream; @@ -46,6 +46,7 @@ public final class StreamingTranscoder { */ public StreamingTranscoder(@NonNull MediaDataSource dataSource, @Nullable TranscoderOptions options, + @NonNull TranscodingPreset preset, long upperSizeLimit, boolean allowAudioRemux) throws IOException, VideoSourceException @@ -69,8 +70,8 @@ public final class StreamingTranscoder { } this.inSize = dataSource.getSize(); - this.inputBitRate = VideoBitRateCalculator.bitRate(inSize, duration); - this.targetQuality = new VideoBitRateCalculator(upperSizeLimit).getTargetQuality(duration, inputBitRate); + this.inputBitRate = TranscodingQuality.bitRate(inSize, duration); + this.targetQuality = TranscodingQuality.createFromPreset(preset, duration); this.upperSizeLimit = upperSizeLimit; this.transcodeRequired = inputBitRate >= targetQuality.getTargetTotalBitRate() * 1.2 || inSize > upperSizeLimit || containsLocation(mediaMetadataRetriever) || options != null; @@ -78,12 +79,13 @@ public final class StreamingTranscoder { Log.i(TAG, "Video is within 20% of target bitrate, below the size limit, contained no location metadata or custom options."); } - this.fileSizeEstimate = targetQuality.getFileSizeEstimate(); + this.fileSizeEstimate = targetQuality.getByteCountEstimate(); } - public StreamingTranscoder(@NonNull MediaDataSource dataSource, + private StreamingTranscoder(@NonNull MediaDataSource dataSource, @Nullable TranscoderOptions options, int videoBitrate, + int audioBitrate, int shortEdge, boolean allowAudioRemux) throws IOException, VideoSourceException @@ -102,13 +104,25 @@ public final class StreamingTranscoder { this.inSize = dataSource.getSize(); this.duration = getDuration(mediaMetadataRetriever); - this.inputBitRate = VideoBitRateCalculator.bitRate(inSize, duration); - this.targetQuality = new TranscodingQuality(videoBitrate, VideoConstants.AUDIO_BIT_RATE, 1.0, duration, shortEdge); + this.inputBitRate = TranscodingQuality.bitRate(inSize, duration); + this.targetQuality = TranscodingQuality.createManuallyForTesting(shortEdge, videoBitrate, audioBitrate, duration); this.upperSizeLimit = 0L; this.transcodeRequired = true; - this.fileSizeEstimate = targetQuality.getFileSizeEstimate(); + this.fileSizeEstimate = targetQuality.getByteCountEstimate(); + } + + @VisibleForTesting + public static StreamingTranscoder createManuallyForTesting(@NonNull MediaDataSource dataSource, + @Nullable TranscoderOptions options, + int videoBitrate, + int audioBitrate, + int shortEdge, + boolean allowAudioRemux) + throws VideoSourceException, IOException + { + return new StreamingTranscoder(dataSource, options, videoBitrate, audioBitrate, shortEdge, allowAudioRemux); } public void transcode(@NonNull Progress progress, @@ -193,7 +207,7 @@ public final class StreamingTranscoder { numberFormat.format(outSize / 1024), (outSize * 100d) / inSize, (outSize * 100d) / fileSizeEstimate, - numberFormat.format(VideoBitRateCalculator.bitRate(outSize, duration)))); + numberFormat.format(TranscodingQuality.bitRate(outSize, duration)))); if (sizeLimitEnabled && outSize > upperSizeLimit) { throw new VideoSizeException("Size constraints could not be met!"); diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscodingQuality.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscodingQuality.kt index b264078585..c06d267bc2 100644 --- a/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscodingQuality.kt +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscodingQuality.kt @@ -4,26 +4,51 @@ */ package org.thoughtcrime.securesms.video +import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants + /** * A data class to hold various video transcoding parameters, such as bitrate. */ -data class TranscodingQuality(val targetVideoBitRate: Int, val targetAudioBitRate: Int, val quality: Double, private val duration: Long, val outputResolution: Int) { - init { - if (quality < 0.0 || quality > 1.0) { - throw IllegalArgumentException("Quality $quality is outside of accepted range [0.0, 1.0]!") +class TranscodingQuality private constructor(val outputResolution: Int, val targetVideoBitRate: Int, val targetAudioBitRate: Int, private val durationMs: Long) { + companion object { + + @JvmStatic + fun createFromPreset(preset: TranscodingPreset, durationMs: Long): TranscodingQuality { + return TranscodingQuality(preset.videoShortEdge, preset.videoBitRate, preset.audioBitRate, durationMs) + } + + @JvmStatic + fun createManuallyForTesting(outputShortEdge: Int, videoBitrate: Int, audioBitrate: Int, durationMs: Long): TranscodingQuality { + return TranscodingQuality(outputShortEdge, videoBitrate, audioBitrate, durationMs) + } + + @JvmStatic + fun bitRate(bytes: Long, durationMs: Long): Int { + return (bytes * 8 / (durationMs / 1000f)).toInt() } } val targetTotalBitRate = targetVideoBitRate + targetAudioBitRate - val fileSizeEstimate = targetTotalBitRate * duration / 8000 + val byteCountEstimate = (targetTotalBitRate / 8) * (durationMs / 1000) override fun toString(): String { return "Quality{" + "targetVideoBitRate=" + targetVideoBitRate + ", targetAudioBitRate=" + targetAudioBitRate + - ", quality=" + quality + - ", duration=" + duration + - ", filesize=" + fileSizeEstimate + + ", duration=" + durationMs + + ", filesize=" + byteCountEstimate + '}' } } + +enum class TranscodingPreset(val videoShortEdge: Int, val videoBitRate: Int, val audioBitRate: Int) { + LEVEL_1(VideoConstants.VIDEO_SHORT_EDGE_SD, VideoConstants.VIDEO_BITRATE_L1, VideoConstants.AUDIO_BITRATE), + LEVEL_2(VideoConstants.VIDEO_SHORT_EDGE_HD, VideoConstants.VIDEO_BITRATE_L2, VideoConstants.AUDIO_BITRATE), + LEVEL_3(VideoConstants.VIDEO_SHORT_EDGE_HD, VideoConstants.VIDEO_BITRATE_L3, VideoConstants.AUDIO_BITRATE); + + fun calculateMaxVideoUploadDurationInSeconds(upperFileSizeLimit: Long): Int { + val upperFileSizeLimitWithMargin = (upperFileSizeLimit / 1.1).toLong() + val totalBitRate = videoBitRate + audioBitRate + return Math.toIntExact((upperFileSizeLimitWithMargin * 8) / totalBitRate) + } +} diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java b/video/lib/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java deleted file mode 100644 index 47bb0c7b79..0000000000 --- a/video/lib/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.thoughtcrime.securesms.video; - -import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants; - -/** - * Calculates a target quality output for a video to fit within a specified size. - */ -public final class VideoBitRateCalculator { - - private final long upperFileSizeLimitWithMargin; - - public VideoBitRateCalculator(long upperFileSizeLimit) { - upperFileSizeLimitWithMargin = (long) (upperFileSizeLimit / 1.1); - } - - /** - * Gets the output quality of a video of the given {@param duration}. - */ - public TranscodingQuality getTargetQuality(long duration, int inputTotalBitRate) { - int maxVideoBitRate = Math.min(VideoConstants.VIDEO_TARGET_BIT_RATE, inputTotalBitRate - VideoConstants.AUDIO_BIT_RATE); - int minVideoBitRate = Math.min(VideoConstants.VIDEO_MINIMUM_TARGET_BIT_RATE, maxVideoBitRate); - - int targetVideoBitRate = Math.max(minVideoBitRate, Math.min(getTargetVideoBitRate(upperFileSizeLimitWithMargin, duration), maxVideoBitRate)); - int bitRateRange = maxVideoBitRate - minVideoBitRate; - double quality = bitRateRange == 0 ? 1 : (targetVideoBitRate - minVideoBitRate) / (double) bitRateRange; - int outputResolution = targetVideoBitRate < VideoConstants.LOW_RES_TARGET_VIDEO_BITRATE ? VideoConstants.LOW_RES_OUTPUT_FORMAT : VideoConstants.VIDEO_SHORT_EDGE; - - return new TranscodingQuality(targetVideoBitRate, VideoConstants.AUDIO_BIT_RATE, Math.max(0, Math.min(quality, 1)), duration, outputResolution); - } - - private int getTargetVideoBitRate(long sizeGuideBytes, long duration) { - double durationSeconds = duration / 1000d; - - sizeGuideBytes -= durationSeconds * VideoConstants.AUDIO_BIT_RATE / 8; - - double targetAttachmentSizeBits = sizeGuideBytes * 8L; - - return (int) (targetAttachmentSizeBits / durationSeconds); - } - - public static int bitRate(long bytes, long durationMs) { - return (int) (bytes * 8 / (durationMs / 1000f)); - } - - public int getMaxVideoUploadDurationInSeconds() { - long totalMinimumBitrate = VideoConstants.VIDEO_MINIMUM_TARGET_BIT_RATE + VideoConstants.AUDIO_BIT_RATE; - return Math.toIntExact((upperFileSizeLimitWithMargin * 8) / totalMinimumBitrate); - } -} diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/interfaces/MediaInput.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/interfaces/MediaInput.kt index a961eb52c2..32c8142685 100644 --- a/video/lib/src/main/java/org/thoughtcrime/securesms/video/interfaces/MediaInput.kt +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/interfaces/MediaInput.kt @@ -15,4 +15,6 @@ import java.io.IOException interface MediaInput : Closeable { @Throws(IOException::class) fun createExtractor(): MediaExtractor + + fun hasSameInput(other: MediaInput): Boolean } diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/MediaDataSourceMediaInput.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/MediaDataSourceMediaInput.kt index 74f42a57fc..12f87c8604 100644 --- a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/MediaDataSourceMediaInput.kt +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/MediaDataSourceMediaInput.kt @@ -23,6 +23,10 @@ class MediaDataSourceMediaInput(private val mediaDataSource: MediaDataSource) : } } + override fun hasSameInput(other: MediaInput): Boolean { + return other is MediaDataSourceMediaInput && other.mediaDataSource == this.mediaDataSource + } + @Throws(IOException::class) override fun close() { mediaDataSource.close() diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/utils/VideoConstants.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/utils/VideoConstants.kt index 9d55b90030..418f97846e 100644 --- a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/utils/VideoConstants.kt +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/utils/VideoConstants.kt @@ -7,16 +7,15 @@ package org.thoughtcrime.securesms.video.videoconverter.utils import android.media.MediaFormat object VideoConstants { - const val AUDIO_BIT_RATE = 192_000 - const val VIDEO_FRAME_RATE = 30 - const val VIDEO_TARGET_BIT_RATE = 2_000_000 - const val VIDEO_MINIMUM_TARGET_BIT_RATE = 500_000 - const val LOW_RES_TARGET_VIDEO_BITRATE = 1_750_000 - const val LOW_RES_OUTPUT_FORMAT = 480 - const val VIDEO_SHORT_EDGE = 720 - const val VIDEO_LONG_EDGE = 1280 + const val AUDIO_BITRATE = 128_000 + const val VIDEO_BITRATE_L1 = 1_250_000 + const val VIDEO_BITRATE_L2 = VIDEO_BITRATE_L1 + const val VIDEO_BITRATE_L3 = 2_500_000 + const val VIDEO_SHORT_EDGE_SD = 480 + const val VIDEO_SHORT_EDGE_HD = 720 + const val VIDEO_LONG_EDGE_HD = 1280 const val VIDEO_MAX_RECORD_LENGTH_S = 60 - const val TOTAL_BYTES_PER_SECOND = VIDEO_TARGET_BIT_RATE / 8 + AUDIO_BIT_RATE / 8 + const val MAX_ALLOWED_BYTES_PER_SECOND = VIDEO_BITRATE_L3 / 8 + AUDIO_BITRATE / 8 const val VIDEO_MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC const val AUDIO_MIME_TYPE = MediaFormat.MIMETYPE_AUDIO_AAC const val RECORDED_VIDEO_CONTENT_TYPE: String = "video/mp4"