mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 02:10:44 +01:00
Video Sending Redesign
This commit is contained in:
committed by
Alex Hart
parent
276e253fdf
commit
c53abe0941
@@ -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.")
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ public class LinkPreviewRepository {
|
||||
bitmap,
|
||||
maxDimension,
|
||||
mediaConfig.getMaxImageFileSize(),
|
||||
mediaConfig.getQualitySetting()
|
||||
mediaConfig.getImageQualitySetting()
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,8 +15,6 @@ public interface MediaSendPageFragment {
|
||||
|
||||
void setUri(@NonNull Uri uri);
|
||||
|
||||
@Nullable View getPlaybackControls();
|
||||
|
||||
@Nullable Object saveState();
|
||||
|
||||
void restoreState(@NonNull Object state);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<Media>> = BehaviorSubject.create()
|
||||
|
||||
private val store: Store<MediaSelectionState> = 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(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ConstraintLayout.LayoutParams> {
|
||||
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<Animator>()
|
||||
|
||||
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<Animator> {
|
||||
return if (state.isTouchEnabled) {
|
||||
listOf(MediaReviewAnimatorController.getFadeInAnimator(controlsShade))
|
||||
val animators = mutableListOf<Animator>()
|
||||
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<Animator> {
|
||||
val animators = mutableListOf<Animator>()
|
||||
|
||||
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<Animator> {
|
||||
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<Animator> {
|
||||
return if (state.isTouchEnabled && !state.isStory && !SignalStore.settings().isPreferSystemEmoji) {
|
||||
listOf(MediaReviewAnimatorController.getFadeInAnimator(emojiButton))
|
||||
} else {
|
||||
listOf(MediaReviewAnimatorController.getFadeOutAnimator(emojiButton))
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeAddMediaButtonsAnimators(state: MediaSelectionState): List<Animator> {
|
||||
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<Animator> {
|
||||
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<Animator> {
|
||||
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<Animator> {
|
||||
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<Animator> {
|
||||
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<Animator> {
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Rect> 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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!");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Bitmap> thumbnails;
|
||||
private MediaInput input;
|
||||
private volatile ArrayList<Bitmap> thumbnails;
|
||||
private AsyncTask<Void, Bitmap, Void> 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user