Video Sending Redesign

This commit is contained in:
Nicholas Tinsley
2024-03-01 13:15:08 -05:00
committed by Alex Hart
parent 276e253fdf
commit c53abe0941
65 changed files with 1830 additions and 1621 deletions

View File

@@ -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.")

View File

@@ -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");

View File

@@ -244,7 +244,7 @@ public class LinkPreviewRepository {
bitmap,
maxDimension,
mediaConfig.getMaxImageFileSize(),
mediaConfig.getQualitySetting()
mediaConfig.getImageQualitySetting()
);
if (result != null) {

View File

@@ -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() {}
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -15,8 +15,6 @@ public interface MediaSendPageFragment {
void setUri(@NonNull Uri uri);
@Nullable View getPlaybackControls();
@Nullable Object saveState();
void restoreState(@NonNull Object state);

View File

@@ -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);
}
}

View File

@@ -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
}
}
}

View File

@@ -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)));
}
}

View File

@@ -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))
)
}
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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())
}

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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()

View File

@@ -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
}
}
}

View File

@@ -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) {}
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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)
)
}
}
}

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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!");

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}