diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 4569c5ec00..b0219c3de0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -8,11 +8,14 @@ import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.Observer; +import org.thoughtcrime.securesms.components.MaskView; import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.conversation.ConversationItem; import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; import org.thoughtcrime.securesms.linkpreview.LinkPreview; @@ -20,13 +23,14 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import org.whispersystems.libsignal.util.guava.Optional; import java.util.List; import java.util.Locale; import java.util.Set; -public interface BindableConversationItem extends Unbindable { +public interface BindableConversationItem extends Unbindable, GiphyMp4Playable { void bind(@NonNull LifecycleOwner lifecycleOwner, @NonNull ConversationMessage messageRecord, @NonNull Optional previousMessageRecord, @@ -38,7 +42,9 @@ public interface BindableConversationItem extends Unbindable { @Nullable String searchQuery, boolean pulseMention, boolean hasWallpaper, - boolean isMessageRequestAccepted); + boolean isMessageRequestAccepted, + @NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory, + boolean canPlayInline); ConversationMessage getConversationMessage(); @@ -68,6 +74,7 @@ public interface BindableConversationItem extends Unbindable { void onJoinGroupCallClicked(); void onInviteFriendsToGroupClicked(@NonNull GroupId.V2 groupId); void onEnableCallNotificationsClicked(); + void onPlayInlineContent(ConversationMessage conversationMessage); /** @return true if handled, false if you want to let the normal url handling continue */ boolean onUrlClicked(@NonNull String url); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java index 7a4a376e12..c5cfb9df6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java @@ -12,7 +12,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; import androidx.core.content.ContextCompat; -import androidx.lifecycle.Lifecycle; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; @@ -20,6 +19,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlidesClickedListener; +import org.thoughtcrime.securesms.util.ViewUtil; import java.util.List; @@ -33,6 +33,8 @@ public class ConversationItemThumbnail extends FrameLayout { private Outliner outliner; private Outliner pulseOutliner; private boolean borderless; + private int[] normalBounds; + private int[] gifBounds; public ConversationItemThumbnail(Context context) { super(context); @@ -61,14 +63,28 @@ public class ConversationItemThumbnail extends FrameLayout { outliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20)); + int gifWidth = ViewUtil.dpToPx(260); if (attrs != null) { TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0); - thumbnail.setBounds(typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0), - typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0), - typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0), - typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0)); + normalBounds = new int[]{ + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0), + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0), + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0), + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0) + }; + + gifWidth = typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_gifWidth, gifWidth); typedArray.recycle(); + } else { + normalBounds = new int[]{0, 0, 0, 0}; } + + gifBounds = new int[]{ + gifWidth, + gifWidth, + 1, + Integer.MAX_VALUE + }; } @SuppressWarnings("SuspiciousNameCombination") @@ -89,6 +105,18 @@ public class ConversationItemThumbnail extends FrameLayout { } } + public void hideThumbnailView() { + thumbnail.setAlpha(0f); + } + + public void showThumbnailView() { + thumbnail.setAlpha(1f); + } + + public @Nullable CornerMask getCornerMask() { + return cornerMask; + } + public void setPulseOutliner(@NonNull Outliner outliner) { this.pulseOutliner = outliner; } @@ -138,6 +166,13 @@ public class ConversationItemThumbnail extends FrameLayout { boolean showControls, boolean isPreview) { if (slides.size() == 1) { + Slide slide = slides.get(0); + if (slide.isVideoGif()) { + setThumbnailBounds(gifBounds); + } else { + setThumbnailBounds(normalBounds); + } + thumbnail.setVisibility(VISIBLE); album.setVisibility(GONE); @@ -168,4 +203,8 @@ public class ConversationItemThumbnail extends FrameLayout { thumbnail.setDownloadClickListener(listener); album.setDownloadClickListener(listener); } + + private void setThumbnailBounds(@NonNull int[] bounds) { + thumbnail.setBounds(bounds[0], bounds[1], bounds[2], bounds[3]); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java index 2d27539ac1..9bccf370e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java @@ -7,9 +7,11 @@ import android.graphics.Path; import android.graphics.PorterDuff; import android.graphics.PorterDuffXfermode; import android.graphics.RectF; +import android.graphics.drawable.shapes.RoundRectShape; import android.view.View; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; public class CornerMask { @@ -20,19 +22,24 @@ public class CornerMask { private final RectF bounds = new RectF(); public CornerMask(@NonNull View view) { + this(view, null); + } + + public CornerMask(@NonNull View view, @Nullable CornerMask toClone) { view.setLayerType(View.LAYER_TYPE_HARDWARE, null); clearPaint.setColor(Color.BLACK); clearPaint.setStyle(Paint.Style.FILL); clearPaint.setAntiAlias(true); clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + + if (toClone != null) { + System.arraycopy(toClone.radii, 0, radii, 0, radii.length); + } } public void mask(Canvas canvas) { - bounds.left = 0; - bounds.top = 0; - bounds.right = canvas.getWidth(); - bounds.bottom = canvas.getHeight(); + bounds.set(canvas.getClipBounds()); corners.reset(); corners.addRoundRect(bounds, radii, Path.Direction.CW); @@ -72,4 +79,8 @@ public class CornerMask { public void setBottomLeftRadius(int radius) { radii[6] = radii[7] = radius; } + + public float[] getRadii() { + return radii; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java b/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java index 1da8dfddfb..69ba95ce2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java @@ -15,13 +15,17 @@ import android.view.ViewTreeObserver; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + public class MaskView extends View { - private View target; - private ViewGroup activityContentView; - private Paint maskPaint; - private Rect drawingRect = new Rect(); - private float targetParentTranslationY; + private MaskTarget maskTarget; + private ViewGroup activityContentView; + private Paint maskPaint; + private Rect drawingRect = new Rect(); + private float targetParentTranslationY; private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate; @@ -50,15 +54,15 @@ public class MaskView extends View { activityContentView = getRootView().findViewById(android.R.id.content); } - public void setTarget(@Nullable View target) { - if (this.target != null) { - this.target.getViewTreeObserver().removeOnDrawListener(onDrawListener); + public void setTarget(@Nullable MaskTarget maskTarget) { + if (this.maskTarget != null) { + removeOnDrawListener(this.maskTarget, onDrawListener); } - this.target = target; + this.maskTarget = maskTarget; - if (this.target != null) { - this.target.getViewTreeObserver().addOnDrawListener(onDrawListener); + if (this.maskTarget != null) { + addOnDrawListener(maskTarget, onDrawListener); } invalidate(); @@ -72,26 +76,77 @@ public class MaskView extends View { protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); - if (target == null || !target.isAttachedToWindow()) { + if (nothingToMask(maskTarget)) { return; } - target.getDrawingRect(drawingRect); - activityContentView.offsetDescendantRectToMyCoords(target, drawingRect); + maskTarget.getPrimaryTarget().getDrawingRect(drawingRect); + activityContentView.offsetDescendantRectToMyCoords(maskTarget.getPrimaryTarget(), drawingRect); drawingRect.top += targetParentTranslationY; drawingRect.bottom += targetParentTranslationY; - Bitmap mask = Bitmap.createBitmap(target.getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888); + Bitmap mask = Bitmap.createBitmap(maskTarget.getPrimaryTarget().getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888); Canvas maskCanvas = new Canvas(mask); - target.draw(maskCanvas); + maskTarget.draw(maskCanvas); canvas.clipRect(drawingRect.left, Math.max(drawingRect.top, getTop() + getPaddingTop()), drawingRect.right, Math.min(drawingRect.bottom, getBottom() - getPaddingBottom())); - ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) target.getLayoutParams(); + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) maskTarget.getPrimaryTarget().getLayoutParams(); canvas.drawBitmap(mask, params.leftMargin, drawingRect.top, maskPaint); mask.recycle(); } + + private static void removeOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) { + for (View view : maskTarget.getAllTargets()) { + if (view != null) { + view.getViewTreeObserver().removeOnDrawListener(onDrawListener); + } + } + } + + private static void addOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) { + for (View view : maskTarget.getAllTargets()) { + if (view != null) { + view.getViewTreeObserver().addOnDrawListener(onDrawListener); + } + } + } + + private static boolean nothingToMask(@Nullable MaskTarget maskTarget) { + if (maskTarget == null) { + return true; + } + + for (View view : maskTarget.getAllTargets()) { + if (view == null || !view.isAttachedToWindow()) { + return true; + } + } + + return false; + } + + public static class MaskTarget { + + private final View primaryTarget; + + public MaskTarget(@NonNull View primaryTarget) { + this.primaryTarget = primaryTarget; + } + + final @NonNull View getPrimaryTarget() { + return primaryTarget; + } + + protected @NonNull List getAllTargets() { + return Collections.singletonList(primaryTarget); + } + + protected void draw(@NonNull Canvas canvas) { + primaryTarget.draw(canvas); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java index 1978fa27c4..833551aeaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java @@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import java.util.Collections; import java.util.List; @@ -47,9 +48,9 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP private final Context context; private final SimpleExoPlayer player; - private final VoiceNoteQueueDataAdapter queueDataAdapter; - private final VoiceNoteMediaSourceFactory mediaSourceFactory; - private final ConcatenatingMediaSource dataSource; + private final VoiceNoteQueueDataAdapter queueDataAdapter; + private final AttachmentMediaSourceFactory mediaSourceFactory; + private final ConcatenatingMediaSource dataSource; private boolean canLoadMore; private Uri latestUri = Uri.EMPTY; @@ -57,7 +58,7 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP VoiceNotePlaybackPreparer(@NonNull Context context, @NonNull SimpleExoPlayer player, @NonNull VoiceNoteQueueDataAdapter queueDataAdapter, - @NonNull VoiceNoteMediaSourceFactory mediaSourceFactory) + @NonNull AttachmentMediaSourceFactory mediaSourceFactory) { this.context = context; this.player = player; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java index 7f59b58e69..ef469bb516 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java @@ -34,6 +34,7 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.ui.PlayerNotificationManager; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import java.util.Collections; import java.util.List; @@ -87,7 +88,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { new VoiceNoteNotificationManagerListener(), queueDataAdapter); - VoiceNoteMediaSourceFactory mediaSourceFactory = new VoiceNoteMediaSourceFactory(this); + AttachmentMediaSourceFactory mediaSourceFactory = new AttachmentMediaSourceFactory(this); voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory); voiceNoteProximityManager = new VoiceNoteProximityManager(this, player, queueDataAdapter); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 523bbd720e..0b37da8d69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -112,6 +112,7 @@ import org.thoughtcrime.securesms.components.HidingLinearLayout; import org.thoughtcrime.securesms.components.InputAwareLayout; import org.thoughtcrime.securesms.components.InputPanel; import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener; +import org.thoughtcrime.securesms.components.MaskView; import org.thoughtcrime.securesms.components.SendButton; import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.components.TypingStatusSender; @@ -3420,7 +3421,7 @@ public class ConversationActivity extends PassphraseRequiredActivity } @Override - public void handleReaction(@NonNull View maskTarget, + public void handleReaction(@NonNull MaskView.MaskTarget maskTarget, @NonNull MessageRecord messageRecord, @NonNull Toolbar.OnMenuItemClickListener toolbarListener, @NonNull ConversationReactionOverlay.OnHideListener onHideListener) @@ -3451,7 +3452,7 @@ public class ConversationActivity extends PassphraseRequiredActivity } @Override - public void handleReactionDetails(@NonNull View maskTarget) { + public void handleReactionDetails(@NonNull MaskView.MaskTarget maskTarget) { reactionDelegate.showMask(maskTarget, titleView.getMeasuredHeight(), inputAreaHeight()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index 2a885f5517..9e5e9d4075 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -36,12 +36,18 @@ import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.exoplayer2.source.MediaSource; + import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; import org.signal.paging.PagingController; import org.thoughtcrime.securesms.BindableConversationItem; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.MaskView; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.CachedInflater; @@ -50,6 +56,7 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import org.whispersystems.libsignal.util.guava.Optional; import java.security.MessageDigest; @@ -100,11 +107,12 @@ public class ConversationAdapter private final Locale locale; private final Recipient recipient; - private final Set selected; - private final List fastRecords; - private final Set releasedFastRecords; - private final Calendar calendar; - private final MessageDigest digest; + private final Set selected; + private final List fastRecords; + private final Set releasedFastRecords; + private final Calendar calendar; + private final MessageDigest digest; + private final AttachmentMediaSourceFactory attachmentMediaSourceFactory; private String searchQuery; private ConversationMessage recordToPulse; @@ -113,12 +121,14 @@ public class ConversationAdapter private PagingController pagingController; private boolean hasWallpaper; private boolean isMessageRequestAccepted; + private ConversationMessage inlineContent; ConversationAdapter(@NonNull LifecycleOwner lifecycleOwner, @NonNull GlideRequests glideRequests, @NonNull Locale locale, @Nullable ItemClickListener clickListener, - @NonNull Recipient recipient) + @NonNull Recipient recipient, + @NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory) { super(new DiffUtil.ItemCallback() { @Override @@ -134,17 +144,18 @@ public class ConversationAdapter this.lifecycleOwner = lifecycleOwner; - this.glideRequests = glideRequests; - this.locale = locale; - this.clickListener = clickListener; - this.recipient = recipient; - this.selected = new HashSet<>(); - this.fastRecords = new ArrayList<>(); - this.releasedFastRecords = new HashSet<>(); - this.calendar = Calendar.getInstance(); - this.digest = getMessageDigestOrThrow(); - this.hasWallpaper = recipient.hasWallpaper(); - this.isMessageRequestAccepted = true; + this.glideRequests = glideRequests; + this.locale = locale; + this.clickListener = clickListener; + this.recipient = recipient; + this.selected = new HashSet<>(); + this.fastRecords = new ArrayList<>(); + this.releasedFastRecords = new HashSet<>(); + this.calendar = Calendar.getInstance(); + this.digest = getMessageDigestOrThrow(); + this.hasWallpaper = recipient.hasWallpaper(); + this.isMessageRequestAccepted = true; + this.attachmentMediaSourceFactory = attachmentMediaSourceFactory; setHasStableIds(true); } @@ -257,7 +268,9 @@ public class ConversationAdapter searchQuery, conversationMessage == recordToPulse, hasWallpaper, - isMessageRequestAccepted); + isMessageRequestAccepted, + attachmentMediaSourceFactory, + conversationMessage == inlineContent); if (conversationMessage == recordToPulse) { recordToPulse = null; @@ -610,7 +623,14 @@ public class ConversationAdapter } } - static class ConversationViewHolder extends RecyclerView.ViewHolder { + public void playInlineContent(@Nullable ConversationMessage conversationMessage) { + if (this.inlineContent != conversationMessage) { + this.inlineContent = conversationMessage; + notifyDataSetChanged(); + } + } + + final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable { public ConversationViewHolder(final @NonNull View itemView) { super(itemView); } @@ -618,6 +638,36 @@ public class ConversationAdapter public BindableConversationItem getBindable() { return (BindableConversationItem) itemView; } + + @Override + public void showProjectionArea() { + getBindable().showProjectionArea(); + } + + @Override + public void hideProjectionArea() { + getBindable().hideProjectionArea(); + } + + @Override + public @Nullable MediaSource getMediaSource() { + return getBindable().getMediaSource(); + } + + @Override + public @Nullable GiphyMp4PlaybackPolicyEnforcer getPlaybackPolicyEnforcer() { + return getBindable().getPlaybackPolicyEnforcer(); + } + + @NonNull + public @Override GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) { + return getBindable().getProjection(recyclerView); + } + + @Override + public boolean canPlayContent() { + return getBindable().canPlayContent(); + } } static class StickyHeaderViewHolder extends RecyclerView.ViewHolder { @@ -688,6 +738,6 @@ public class ConversationAdapter interface ItemClickListener extends BindableConversationItem.EventListener { void onItemClick(ConversationMessage item); - void onItemLongClick(View maskTarget, ConversationMessage item); + void onItemLongClick(View itemView, ConversationMessage item); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index ad27056564..7d7331d48d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.VerifyIdentityActivity; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.components.ConversationScrollToView; import org.thoughtcrime.securesms.components.ConversationTypingView; +import org.thoughtcrime.securesms.components.MaskView; import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.components.TypingStatusRepository; import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; @@ -97,6 +98,10 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite.GroupLinkInviteFriendsBottomSheetDialogFragment; @@ -148,6 +153,7 @@ import org.thoughtcrime.securesms.util.WindowUtil; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; import org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar; +import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.whispersystems.libsignal.util.guava.Optional; @@ -177,6 +183,7 @@ public class ConversationFragment extends LoggingFragment { private boolean isReacting; private ActionMode actionMode; private Locale locale; + private FrameLayout videoContainer; private RecyclerView list; private RecyclerView.ItemDecoration lastSeenDecoration; private RecyclerView.ItemDecoration inlineDateDecoration; @@ -204,6 +211,8 @@ public class ConversationFragment extends LoggingFragment { private View toolbarShadow; private Stopwatch startupStopwatch; + private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler; + public static void prepare(@NonNull Context context) { FrameLayout parent = new FrameLayout(context); parent.setLayoutParams(new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT)); @@ -226,6 +235,7 @@ public class ConversationFragment extends LoggingFragment { @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) { final View view = inflater.inflate(R.layout.conversation_fragment, container, false); + videoContainer = view.findViewById(R.id.video_container); list = view.findViewById(android.R.id.list); composeDivider = view.findViewById(R.id.compose_divider); @@ -250,13 +260,16 @@ public class ConversationFragment extends LoggingFragment { typingView = (ConversationTypingView) inflater.inflate(R.layout.conversation_typing_view, container, false); + giphyMp4ProjectionRecycler = initializeGiphyMp4(); + new ConversationItemSwipeCallback( conversationMessage -> actionMode == null && MenuState.canReplyToMessage(recipient.get(), MenuState.isActionMessage(conversationMessage.getMessageRecord()), conversationMessage.getMessageRecord(), messageRequestViewModel.shouldShowMessageRequest()), - this::handleReplyMessage + this::handleReplyMessage, + giphyMp4ProjectionRecycler ).attachToRecyclerView(list); setupListLayoutListeners(); @@ -297,6 +310,26 @@ public class ConversationFragment extends LoggingFragment { return view; } + private @NonNull GiphyMp4ProjectionRecycler initializeGiphyMp4() { + int maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation(); + List holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(), + getViewLifecycleOwner().getLifecycle(), + videoContainer, + maxPlayback); + GiphyMp4ProjectionRecycler callback = new GiphyMp4ProjectionRecycler(holders); + + GiphyMp4PlaybackController.attach(list, callback, maxPlayback); + + return callback; + } + + private @NonNull MaskView.MaskTarget getMaskTarget(@NonNull View itemView) { + int adapterPosition = list.getChildAdapterPosition(itemView); + View videoPlayer = giphyMp4ProjectionRecycler.getVideoPlayerAtAdapterPosition(adapterPosition); + + return new ConversationItemMaskTarget((ConversationItem) itemView, videoPlayer); + } + private void setupListLayoutListeners() { list.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> setListVerticalTranslation()); @@ -557,7 +590,7 @@ public class ConversationFragment extends LoggingFragment { private void initializeListAdapter() { if (this.recipient != null && this.threadId != -1) { Log.d(TAG, "Initializing adapter for " + recipient.getId()); - ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get()); + ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext())); adapter.setPagingController(conversationViewModel.getPagingController()); list.setAdapter(adapter); setInlineDateDecoration(adapter); @@ -1191,14 +1224,14 @@ public class ConversationFragment extends LoggingFragment { void onMessageActionToolbarOpened(); void onForwardClicked(); void onMessageRequest(@NonNull MessageRequestViewModel viewModel); - void handleReaction(@NonNull View maskTarget, + void handleReaction(@NonNull MaskView.MaskTarget maskTarget, @NonNull MessageRecord messageRecord, @NonNull Toolbar.OnMenuItemClickListener toolbarListener, @NonNull ConversationReactionOverlay.OnHideListener onHideListener); void onCursorChanged(); void onListVerticalTranslationChanged(float translationY); void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); - void handleReactionDetails(@NonNull View maskTarget); + void handleReactionDetails(@NonNull MaskView.MaskTarget maskTarget); } private class ConversationScrollListener extends OnScrollListener { @@ -1288,7 +1321,7 @@ public class ConversationFragment extends LoggingFragment { } @Override - public void onItemLongClick(View maskTarget, ConversationMessage conversationMessage) { + public void onItemLongClick(View itemView, ConversationMessage conversationMessage) { if (actionMode != null) return; @@ -1304,7 +1337,7 @@ public class ConversationFragment extends LoggingFragment { { isReacting = true; list.setLayoutFrozen(true); - listener.handleReaction(maskTarget, messageRecord, new ReactionsToolbarListener(conversationMessage), () -> { + listener.handleReaction(getMaskTarget(itemView), messageRecord, new ReactionsToolbarListener(conversationMessage), () -> { isReacting = false; list.setLayoutFrozen(false); WindowUtil.setLightStatusBarFromTheme(requireActivity()); @@ -1452,7 +1485,7 @@ public class ConversationFragment extends LoggingFragment { public void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms) { if (getContext() == null) return; - listener.handleReactionDetails(reactionTarget); + listener.handleReactionDetails(getMaskTarget(reactionTarget)); ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null); } @@ -1571,6 +1604,11 @@ public class ConversationFragment extends LoggingFragment { refreshList(); } } + + @Override + public void onPlayInlineContent(ConversationMessage conversationMessage) { + getListAdapter().playInlineContent(conversationMessage); + } } public void refreshList() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 29b720cf91..8d6c2d53ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -55,8 +55,10 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; import androidx.lifecycle.LifecycleOwner; +import androidx.recyclerview.widget.RecyclerView; import com.annimon.stream.Stream; +import com.google.android.exoplayer2.source.MediaSource; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.BindableConversationItem; @@ -70,6 +72,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.BorderlessImageView; import org.thoughtcrime.securesms.components.ConversationItemFooter; import org.thoughtcrime.securesms.components.ConversationItemThumbnail; +import org.thoughtcrime.securesms.components.CornerMask; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.LinkPreviewView; import org.thoughtcrime.securesms.components.Outliner; @@ -87,6 +90,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.Quote; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection; import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob; import org.thoughtcrime.securesms.jobs.MmsDownloadJob; import org.thoughtcrime.securesms.jobs.MmsSendJob; @@ -100,6 +106,7 @@ import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlidesClickedListener; import org.thoughtcrime.securesms.mms.TextSlide; +import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.reactions.ReactionsConversationView; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; @@ -120,6 +127,7 @@ import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.VibrateUtil; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.views.Stub; +import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import org.whispersystems.libsignal.util.guava.Optional; import java.util.ArrayList; @@ -198,9 +206,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener(); private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener(); private final UrlClickListener urlClickListener = new UrlClickListener(); + private final Rect thumbnailMaskingRect = new Rect(); private final Context context; + private MediaSource mediaSource; + private boolean canPlayContent; + public ConversationItem(Context context) { this(context, null); } @@ -260,7 +272,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo @Nullable String searchQuery, boolean pulse, boolean hasWallpaper, - boolean isMessageRequestAccepted) + boolean isMessageRequestAccepted, + @NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory, + boolean allowedToPlayInline) { if (this.recipient != null) this.recipient.removeForeverObserver(this); if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this); @@ -275,13 +289,15 @@ public final class ConversationItem extends RelativeLayout implements BindableCo this.conversationRecipient = conversationRecipient.live(); this.groupThread = conversationRecipient.isGroup(); this.recipient = messageRecord.getIndividualRecipient().live(); + this.canPlayContent = false; + this.mediaSource = null; this.recipient.observeForever(this); this.conversationRecipient.observeForever(this); setGutterSizes(messageRecord, groupThread); setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread); - setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted); + setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted, attachmentMediaSourceFactory, allowedToPlayInline); setBodyText(messageRecord, searchQuery, isMessageRequestAccepted); setBubbleState(messageRecord, hasWallpaper); setInteractionState(conversationMessage, pulse); @@ -675,12 +691,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } - private void setMediaAttributes(@NonNull MessageRecord messageRecord, - @NonNull Optional previousRecord, - @NonNull Optional nextRecord, - boolean isGroupThread, - boolean hasWallpaper, - boolean messageRequestAccepted) + private void setMediaAttributes(@NonNull MessageRecord messageRecord, + @NonNull Optional previousRecord, + @NonNull Optional nextRecord, + boolean isGroupThread, + boolean hasWallpaper, + boolean messageRequestAccepted, + @Nullable AttachmentMediaSourceFactory attachmentMediaSourceFactory, + boolean allowedToPlayInline) { boolean showControls = !messageRecord.isFailed(); @@ -865,6 +883,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); footer.setVisibility(VISIBLE); + + if (attachmentMediaSourceFactory != null && + thumbnailSlides.size() == 1 && + thumbnailSlides.get(0).isVideoGif() && + thumbnailSlides.get(0) instanceof VideoSlide) + { + canPlayContent = GiphyMp4PlaybackPolicy.autoplay() || allowedToPlayInline; + mediaSource = attachmentMediaSourceFactory.createMediaSource(Objects.requireNonNull(thumbnailSlides.get(0).getUri())); + } + } else { if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); @@ -1399,6 +1427,68 @@ public final class ConversationItem extends RelativeLayout implements BindableCo return span; } + @Override + public void showProjectionArea() { + if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) { + mediaThumbnailStub.get().showThumbnailView(); + bodyBubble.setMask(null); + } + } + + @Override + public void hideProjectionArea() { + if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) { + mediaThumbnailStub.get().hideThumbnailView(); + mediaThumbnailStub.get().getDrawingRect(thumbnailMaskingRect); + bodyBubble.setMask(thumbnailMaskingRect); + } + } + + @Override + public @Nullable MediaSource getMediaSource() { + return mediaSource; + } + + @Override + public @Nullable GiphyMp4PlaybackPolicyEnforcer getPlaybackPolicyEnforcer() { + if (GiphyMp4PlaybackPolicy.autoplay()) { + return null; + } else { + return new GiphyMp4PlaybackPolicyEnforcer(() -> { + eventListener.onPlayInlineContent(null); + }); + } + } + + @Override + public int getAdapterPosition() { + throw new UnsupportedOperationException("Do not delegate to this method"); + } + + @Override + public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) { + return GiphyMp4Projection.forView(recyclerView, mediaThumbnailStub.get(), mediaThumbnailStub.get().getCornerMask()) + .translateX(bodyBubble.getTranslationX()); + } + + @Override + public boolean canPlayContent() { + return mediaThumbnailStub != null && canPlayContent; + } + + public @NonNull Rect getThumbnailMaskingRect(@NonNull ViewGroup parent) { + Rect rect = new Rect(); + rect.set(thumbnailMaskingRect); + + parent.offsetDescendantRectToMyCoords(mediaThumbnailStub.get(), rect); + + return rect; + } + + public @NonNull CornerMask getThumbnailCornerMask(@NonNull View view) { + return new CornerMask(view, mediaThumbnailStub.get().getCornerMask()); + } + private class SharedContactEventListener implements SharedContactView.EventListener { @Override public void onAddToContactsClicked(@NonNull Contact contact) { @@ -1526,6 +1616,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo public void onClick(final View v, final Slide slide) { if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { performClick(); + } else if (!canPlayContent && mediaSource != null && eventListener != null) { + eventListener.onPlayInlineContent(conversationMessage); } else if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { Intent intent = new Intent(context, MediaPreviewActivity.class); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java index af1b4be7c1..6ec61f5892 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java @@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.conversation; import android.content.Context; import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.widget.LinearLayout; @@ -19,6 +21,9 @@ public class ConversationItemBodyBubble extends LinearLayout { @Nullable private List outliners = Collections.emptyList(); @Nullable private OnSizeChangedListener sizeChangedListener; + private MaskDrawable maskDrawable; + private Rect mask; + public ConversationItemBodyBubble(Context context) { super(context); } @@ -39,6 +44,18 @@ public class ConversationItemBodyBubble extends LinearLayout { this.sizeChangedListener = listener; } + @Override + public void setBackground(Drawable background) { + maskDrawable = new MaskDrawable(background); + maskDrawable.setMask(mask); + super.setBackground(maskDrawable); + } + + public void setMask(@Nullable Rect mask) { + this.mask = mask; + maskDrawable.setMask(mask); + } + @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemMaskTarget.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemMaskTarget.java new file mode 100644 index 0000000000..962a6fb94e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemMaskTarget.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.conversation; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.components.CornerMask; +import org.thoughtcrime.securesms.components.MaskView; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection; + +import java.util.Arrays; +import java.util.List; + +public final class ConversationItemMaskTarget extends MaskView.MaskTarget { + + private final ConversationItem conversationItem; + private final View videoContainer; + + public ConversationItemMaskTarget(@NonNull ConversationItem conversationItem, + @Nullable View videoContainer) + { + super(conversationItem); + this.conversationItem = conversationItem; + this.videoContainer = videoContainer; + } + + @Override + protected @NonNull List getAllTargets() { + if (videoContainer == null) { + return super.getAllTargets(); + } else { + return Arrays.asList(conversationItem, videoContainer); + } + } + + @Override + protected void draw(@NonNull Canvas canvas) { + super.draw(canvas); + + if (videoContainer == null) { + return; + } + + GiphyMp4Projection projection = conversationItem.getProjection((RecyclerView) conversationItem.getParent()); + CornerMask cornerMask = projection.getCornerMask(); + + canvas.clipRect(conversationItem.bodyBubble.getLeft(), + conversationItem.bodyBubble.getTop(), + conversationItem.bodyBubble.getRight(), + conversationItem.bodyBubble.getTop() + projection.getHeight()); + + canvas.drawColor(Color.BLACK); + cornerMask.mask(canvas); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java index af4e438990..4173956beb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java @@ -11,6 +11,8 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4DisplayUpdater; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable; import org.thoughtcrime.securesms.util.AccessibilityUtil; import org.thoughtcrime.securesms.util.ServiceUtil; @@ -28,14 +30,17 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { private final SwipeAvailabilityProvider swipeAvailabilityProvider; private final ConversationItemTouchListener itemTouchListener; private final OnSwipeListener onSwipeListener; + private final GiphyMp4DisplayUpdater giphyMp4DisplayUpdater; ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider, - @NonNull OnSwipeListener onSwipeListener) + @NonNull OnSwipeListener onSwipeListener, + @NonNull GiphyMp4DisplayUpdater giphyMp4DisplayUpdater) { super(0, ItemTouchHelper.END); this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate); this.swipeAvailabilityProvider = swipeAvailabilityProvider; this.onSwipeListener = onSwipeListener; + this.giphyMp4DisplayUpdater = giphyMp4DisplayUpdater; this.shouldTriggerSwipeFeedback = true; this.canTriggerSwipe = true; } @@ -88,12 +93,14 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) { ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign); + updateVideoPlayer(recyclerView, viewHolder); handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx)); if (canTriggerSwipe) { setTouchListener(recyclerView, viewHolder, Math.abs(dx)); } } else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) { ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1); + updateVideoPlayer(recyclerView, viewHolder); } if (dx == 0) { @@ -102,6 +109,12 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { } } + private void updateVideoPlayer(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + if (viewHolder instanceof GiphyMp4Playable) { + giphyMp4DisplayUpdater.updateDisplay(recyclerView, (GiphyMp4Playable) viewHolder); + } + } + private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) { if (dx > SWIPE_SUCCESS_DX && shouldTriggerSwipeFeedback) { vibrate(item.getContext()); @@ -134,7 +147,7 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { case MotionEvent.ACTION_CANCEL: swipeBack = true; shouldTriggerSwipeFeedback = false; - resetProgressIfAnimationsDisabled(viewHolder); + resetProgressIfAnimationsDisabled(recyclerView, viewHolder); break; } return false; @@ -156,11 +169,12 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { recyclerView.cancelPendingInputEvents(); } - private static void resetProgressIfAnimationsDisabled(RecyclerView.ViewHolder viewHolder) { + private void resetProgressIfAnimationsDisabled(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { if (AccessibilityUtil.areAnimationsDisabled(viewHolder.itemView.getContext())) { ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0f, getSignFromDirection(viewHolder.itemView)); + updateVideoPlayer(recyclerView, viewHolder); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java index 01532905fb..bd883f3ff9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java @@ -8,6 +8,7 @@ import android.view.View; import androidx.annotation.NonNull; import androidx.appcompat.widget.Toolbar; +import org.thoughtcrime.securesms.components.MaskView; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.views.Stub; @@ -38,7 +39,7 @@ final class ConversationReactionDelegate { } void show(@NonNull Activity activity, - @NonNull View maskTarget, + @NonNull MaskView.MaskTarget maskTarget, @NonNull Recipient conversationRecipient, @NonNull MessageRecord messageRecord, int maskPaddingBottom) @@ -46,7 +47,7 @@ final class ConversationReactionDelegate { resolveOverlay().show(activity, maskTarget, conversationRecipient, messageRecord, maskPaddingBottom, lastSeenDownPoint); } - void showMask(@NonNull View maskTarget, int maskPaddingTop, int maskPaddingBottom) { + void showMask(@NonNull MaskView.MaskTarget maskTarget, int maskPaddingTop, int maskPaddingBottom) { resolveOverlay().showMask(maskTarget, maskPaddingTop, maskPaddingBottom); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index 66c6154eb7..b6593b5eed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -145,7 +145,7 @@ public final class ConversationReactionOverlay extends RelativeLayout { } public void show(@NonNull Activity activity, - @NonNull View maskTarget, + @NonNull MaskView.MaskTarget maskTarget, @NonNull Recipient conversationRecipient, @NonNull MessageRecord messageRecord, int maskPaddingBottom, @@ -209,7 +209,7 @@ public final class ConversationReactionOverlay extends RelativeLayout { } } - public void showMask(@NonNull View maskTarget, int maskPaddingTop, int maskPaddingBottom) { + public void showMask(@NonNull MaskView.MaskTarget maskTarget, int maskPaddingTop, int maskPaddingBottom) { maskView.setPadding(0, maskPaddingTop, 0, maskPaddingBottom); maskView.setTarget(maskTarget); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index d301b34b70..680773b23a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -16,6 +16,7 @@ import androidx.core.content.ContextCompat; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; +import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.button.MaterialButton; @@ -29,6 +30,7 @@ import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil; import org.thoughtcrime.securesms.database.model.LiveUpdateMessage; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.UpdateDescription; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; @@ -40,6 +42,7 @@ import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import org.whispersystems.libsignal.util.guava.Optional; import java.util.Collection; @@ -102,7 +105,9 @@ public final class ConversationUpdateItem extends FrameLayout @Nullable String searchQuery, boolean pulseMention, boolean hasWallpaper, - boolean isMessageRequestAccepted) + boolean isMessageRequestAccepted, + @NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory, + boolean allowedToPlayInline) { this.batchSelected = batchSelected; @@ -182,6 +187,30 @@ public final class ConversationUpdateItem extends FrameLayout public void unbind() { } + @Override + public void showProjectionArea() { + } + + @Override + public void hideProjectionArea() { + throw new UnsupportedOperationException("Call makes no sense for a conversation update item"); + } + + @Override + public int getAdapterPosition() { + throw new UnsupportedOperationException("Don't delegate to this method."); + } + + @Override + public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) { + throw new UnsupportedOperationException("ConversationUpdateItems cannot be projected into."); + } + + @Override + public boolean canPlayContent() { + return false; + } + static final class RecipientObserverManager { private final Observer recipientObserver; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MaskDrawable.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MaskDrawable.java new file mode 100644 index 0000000000..8089aa7540 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MaskDrawable.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.conversation; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Region; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Drawable which lets you punch a hole through another drawable. + */ +public final class MaskDrawable extends Drawable { + + private final RectF bounds = new RectF(); + private final Path clipPath = new Path(); + + private Rect clipRect; + private float[] clipPathRadii; + + private final Drawable wrapped; + + public MaskDrawable(@NonNull Drawable wrapped) { + this.wrapped = wrapped; + } + + @Override + public void draw(@NonNull Canvas canvas) { + if (clipRect == null) { + wrapped.draw(canvas); + return; + } + + canvas.save(); + + if (clipPathRadii != null) { + clipPath.reset(); + bounds.set(clipRect); + clipPath.addRoundRect(bounds, clipPathRadii, Path.Direction.CW); + canvas.clipPath(clipPath, Region.Op.DIFFERENCE); + } else { + canvas.clipRect(clipRect, Region.Op.DIFFERENCE); + } + + wrapped.draw(canvas); + canvas.restore(); + } + + @Override + public void setAlpha(int alpha) { + wrapped.setAlpha(alpha); + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + wrapped.setColorFilter(colorFilter); + } + + @Override + public int getOpacity() { + return wrapped.getOpacity(); + } + + @Override + public void setBounds(int left, int top, int right, int bottom) { + super.setBounds(left, top, right, bottom); + wrapped.setBounds(left, top, right, bottom); + } + + @Override + public boolean getPadding(@NonNull Rect padding) { + return wrapped.getPadding(padding); + } + + public void setMask(@Nullable Rect mask) { + this.clipRect = new Rect(mask); + + invalidateSelf(); + } + + public void setCorners(@Nullable float[] clipPathRadii) { + this.clipPathRadii = clipPathRadii; + + invalidateSelf(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java index 8bb5dd07ca..1a2d8d09b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java @@ -57,7 +57,7 @@ public final class ThreadBodyUtil { for (Slide slide : record.getSlideDeck().getSlides()) { hasVideo |= slide.hasVideo(); hasImage |= slide.hasImage(); - hasGif |= slide instanceof GifSlide; + hasGif |= slide instanceof GifSlide || slide.isVideoGif(); } if (hasGif) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4AdapterPlaybackControllerCallback.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4AdapterPlaybackControllerCallback.java deleted file mode 100644 index 83cb13cb17..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4AdapterPlaybackControllerCallback.java +++ /dev/null @@ -1,116 +0,0 @@ -package org.thoughtcrime.securesms.giph.mp4; - -import android.util.SparseArray; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * Logic for updating content and positioning of videos as the user scrolls the list of gifs. - */ -final class GiphyMp4AdapterPlaybackControllerCallback implements GiphyMp4AdapterPlaybackController.Callback { - - private final List holders; - private final SparseArray playing; - private final SparseArray notPlaying; - - GiphyMp4AdapterPlaybackControllerCallback(@NonNull List holders) { - this.holders = holders; - this.playing = new SparseArray<>(holders.size()); - this.notPlaying = new SparseArray<>(holders.size()); - } - - @Override public void update(@NonNull List holders, - @NonNull GiphyMp4PlaybackRange range) - { - stopAndReleaseAssignedVideos(range); - - for (final GiphyMp4ViewHolder holder : holders) { - if (range.shouldPlayVideo(holder.getAdapterPosition())) { - startPlayback(acquireHolderForPosition(holder.getAdapterPosition()), holder); - } else { - holder.show(); - } - } - - for (final GiphyMp4ViewHolder holder : holders) { - GiphyMp4PlayerHolder playerHolder = getCurrentHolder(holder.getAdapterPosition()); - if (playerHolder != null) { - updateDisplay(playerHolder, holder); - } - } - } - - private void stopAndReleaseAssignedVideos(@NonNull GiphyMp4PlaybackRange playbackRange) { - List markedForDeletion = new ArrayList<>(playing.size()); - for (int i = 0; i < playing.size(); i++) { - if (!playbackRange.shouldPlayVideo(playing.keyAt(i))) { - notPlaying.put(playing.keyAt(i), playing.valueAt(i)); - playing.valueAt(i).setMediaSource(null); - playing.valueAt(i).setOnPlaybackReady(null); - markedForDeletion.add(playing.keyAt(i)); - } - } - - for (final Integer key : markedForDeletion) { - playing.remove(key); - } - } - - private void updateDisplay(@NonNull GiphyMp4PlayerHolder holder, @NonNull GiphyMp4ViewHolder giphyMp4ViewHolder) { - holder.getContainer().setX(giphyMp4ViewHolder.itemView.getX()); - holder.getContainer().setY(giphyMp4ViewHolder.itemView.getY()); - - ViewGroup.LayoutParams params = holder.getContainer().getLayoutParams(); - if (params.width != giphyMp4ViewHolder.itemView.getWidth() || params.height != giphyMp4ViewHolder.itemView.getHeight()) { - params.width = giphyMp4ViewHolder.itemView.getWidth(); - params.height = giphyMp4ViewHolder.itemView.getHeight(); - holder.getContainer().setLayoutParams(params); - } - } - - private void startPlayback(@NonNull GiphyMp4PlayerHolder holder, @NonNull GiphyMp4ViewHolder giphyMp4ViewHolder) { - if (!Objects.equals(holder.getMediaSource(), giphyMp4ViewHolder.getMediaSource())) { - holder.setOnPlaybackReady(null); - giphyMp4ViewHolder.show(); - - holder.setOnPlaybackReady(giphyMp4ViewHolder::hide); - holder.setMediaSource(giphyMp4ViewHolder.getMediaSource()); - } - } - - private @Nullable GiphyMp4PlayerHolder getCurrentHolder(int adapterPosition) { - if (playing.get(adapterPosition) != null) { - return playing.get(adapterPosition); - } else if (notPlaying.get(adapterPosition) != null) { - return notPlaying.get(adapterPosition); - } else { - return null; - } - } - - private @NonNull GiphyMp4PlayerHolder acquireHolderForPosition(int adapterPosition) { - GiphyMp4PlayerHolder holder = playing.get(adapterPosition); - if (holder == null) { - if (notPlaying.size() != 0) { - holder = notPlaying.get(adapterPosition); - if (holder == null) { - int key = notPlaying.keyAt(0); - holder = Objects.requireNonNull(notPlaying.get(key)); - notPlaying.remove(key); - } else { - notPlaying.remove(adapterPosition); - } - } else { - holder = holders.remove(0); - } - playing.put(adapterPosition, holder); - } - return holder; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4DisplayUpdater.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4DisplayUpdater.java new file mode 100644 index 0000000000..a441f2802d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4DisplayUpdater.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.giph.mp4; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Updates the position and size of a GiphyMp4VideoPlayer. For use with gestures which + * move around the projectable areas videos should play back in. + */ +public interface GiphyMp4DisplayUpdater { + void updateDisplay(@NonNull RecyclerView recyclerView, @NonNull GiphyMp4Playable holder); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Fragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Fragment.java index fd92bd76eb..39efbc64c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Fragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Fragment.java @@ -1,9 +1,7 @@ package org.thoughtcrime.securesms.giph.mp4; import android.os.Bundle; -import android.view.LayoutInflater; import android.view.View; -import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.TextView; @@ -15,13 +13,9 @@ import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; - import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import java.util.ArrayList; import java.util.List; /** @@ -55,44 +49,22 @@ public class GiphyMp4Fragment extends Fragment { GiphyMp4ViewModel viewModel = ViewModelProviders.of(requireActivity(), new GiphyMp4ViewModel.Factory(isForMms)).get(GiphyMp4ViewModel.class); GiphyMp4MediaSourceFactory mediaSourceFactory = new GiphyMp4MediaSourceFactory(ApplicationDependencies.getOkHttpClient()); GiphyMp4Adapter adapter = new GiphyMp4Adapter(mediaSourceFactory, viewModel::saveToBlob); - List holders = injectVideoViews(frameLayout); - GiphyMp4AdapterPlaybackControllerCallback callback = new GiphyMp4AdapterPlaybackControllerCallback(holders); + List holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(), + getViewLifecycleOwner().getLifecycle(), + frameLayout, + GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInSearchResults()); + GiphyMp4ProjectionRecycler callback = new GiphyMp4ProjectionRecycler(holders); recycler.setLayoutManager(new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)); recycler.setAdapter(adapter); recycler.setItemAnimator(null); progressBar.show(); - GiphyMp4AdapterPlaybackController.attach(recycler, callback, GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInSearchResults()); + GiphyMp4PlaybackController.attach(recycler, callback, GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInSearchResults()); viewModel.getImages().observe(getViewLifecycleOwner(), images -> { nothingFound.setVisibility(images.isEmpty() ? View.VISIBLE : View.INVISIBLE); adapter.submitList(images, progressBar::hide); }); viewModel.getPagingController().observe(getViewLifecycleOwner(), adapter::setPagingController); } - - private List injectVideoViews(@NonNull ViewGroup viewGroup) { - int nPlayers = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInSearchResults(); - List holders = new ArrayList<>(nPlayers); - GiphyMp4ExoPlayerProvider playerProvider = new GiphyMp4ExoPlayerProvider(requireContext()); - - for (int i = 0; i < nPlayers; i++) { - FrameLayout container = (FrameLayout) LayoutInflater.from(requireContext()) - .inflate(R.layout.giphy_mp4_player, viewGroup, false); - GiphyMp4VideoPlayer player = container.findViewById(R.id.video_player); - ExoPlayer exoPlayer = playerProvider.create(); - GiphyMp4PlayerHolder holder = new GiphyMp4PlayerHolder(container, player); - - getViewLifecycleOwner().getLifecycle().addObserver(player); - player.setExoPlayer(exoPlayer); - player.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FILL); - exoPlayer.addListener(holder); - - holders.add(holder); - viewGroup.addView(container); - } - - return holders; - } - } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java new file mode 100644 index 0000000000..0ee3880756 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.giph.mp4; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.exoplayer2.source.MediaSource; + +public interface GiphyMp4Playable { + /** + * Shows the area in which a video would be projected. Called when a video will not + * play back. + */ + void showProjectionArea(); + + /** + * Hides the area in which a video would be projected. Called when a video is ready + * to play back. + */ + void hideProjectionArea(); + + /** + * @return The MediaSource to play back in the given VideoPlayer + */ + default @Nullable MediaSource getMediaSource() { + return null; + } + + /** + * A Playback policy enforcer, or null to loop forever. + */ + default @Nullable GiphyMp4PlaybackPolicyEnforcer getPlaybackPolicyEnforcer() { + return null; + } + + /** + * @return The position this item is in it's corresponding adapter + */ + int getAdapterPosition(); + + /** + * Width, height, and (x,y) of view which video player will "project" into + */ + @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerview); + + /** + * Specifies whether the content can start playing. + */ + boolean canPlayContent(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4AdapterPlaybackController.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackController.java similarity index 52% rename from app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4AdapterPlaybackController.java rename to app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackController.java index 764691deeb..f261823f07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4AdapterPlaybackController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackController.java @@ -3,33 +3,39 @@ package org.thoughtcrime.securesms.giph.mp4; import android.view.View; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Set; /** - * Controls playback of gifs in a {@link GiphyMp4Adapter}. The maximum number of gifs that will play back at any one + * Controls playback of gifs in a {@link RecyclerView}. The maximum number of gifs that will play back at any one * time is determined by the passed parameter, and the exact gifs that play back is algorithmically determined, starting * with the center-most gifs. *

* This algorithm is devised to play back only those gifs which the user is most likely looking at. */ -final class GiphyMp4AdapterPlaybackController extends RecyclerView.OnScrollListener implements View.OnLayoutChangeListener { +public final class GiphyMp4PlaybackController extends RecyclerView.OnScrollListener implements View.OnLayoutChangeListener { private final int maxSimultaneousPlayback; private final Callback callback; - private GiphyMp4AdapterPlaybackController(@NonNull Callback callback, int maxSimultaneousPlayback) { + private GiphyMp4PlaybackController(@NonNull Callback callback, int maxSimultaneousPlayback) { this.maxSimultaneousPlayback = maxSimultaneousPlayback; this.callback = callback; } public static void attach(@NonNull RecyclerView recyclerView, @NonNull Callback callback, int maxSimultaneousPlayback) { - GiphyMp4AdapterPlaybackController controller = new GiphyMp4AdapterPlaybackController(callback, maxSimultaneousPlayback); + GiphyMp4PlaybackController controller = new GiphyMp4PlaybackController(callback, maxSimultaneousPlayback); recyclerView.addOnScrollListener(controller); recyclerView.addOnLayoutChangeListener(controller); @@ -57,24 +63,30 @@ final class GiphyMp4AdapterPlaybackController extends RecyclerView.OnScrollListe return; } - int[] firstVisiblePositions = findFirstVisibleItemPositions(layoutManager); - int[] lastVisiblePositions = findLastVisibleItemPositions(layoutManager); + List playables = new LinkedList<>(); + Set playablePositions = new HashSet<>(); - GiphyMp4PlaybackRange playbackRange = getPlaybackRangeForMaximumDistance(firstVisiblePositions, lastVisiblePositions); + for (int i = 0; i < recyclerView.getChildCount(); i++) { + RecyclerView.ViewHolder holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i)); - if (playbackRange != null) { - List holders = new LinkedList<>(); + if (holder instanceof GiphyMp4Playable) { + GiphyMp4Playable playable = (GiphyMp4Playable) holder; + playables.add(playable); - for (int i = 0; i < recyclerView.getChildCount(); i++) { - GiphyMp4ViewHolder viewHolder = (GiphyMp4ViewHolder) recyclerView.getChildViewHolder(recyclerView.getChildAt(i)); - holders.add(viewHolder); + if (playable.canPlayContent()) { + playablePositions.add(playable.getAdapterPosition()); + } } - - callback.update(holders, playbackRange); } + + int[] firstVisiblePositions = findFirstVisibleItemPositions(layoutManager); + int[] lastVisiblePositions = findLastVisibleItemPositions(layoutManager); + Set playbackSet = getPlaybackSetForMaximumDistance(playablePositions, firstVisiblePositions, lastVisiblePositions); + + callback.update(recyclerView, playables, playbackSet); } - private @Nullable GiphyMp4PlaybackRange getPlaybackRangeForMaximumDistance(int[] firstVisiblePositions, int[] lastVisiblePositions) { + private @NonNull Set getPlaybackSetForMaximumDistance(@NonNull Set playablePositions, int[] firstVisiblePositions, int[] lastVisiblePositions) { int firstVisiblePosition = Integer.MAX_VALUE; int lastVisiblePosition = Integer.MIN_VALUE; @@ -83,28 +95,15 @@ final class GiphyMp4AdapterPlaybackController extends RecyclerView.OnScrollListe lastVisiblePosition = Math.max(lastVisiblePosition, lastVisiblePositions[i]); } - return getPlaybackRange(firstVisiblePosition, lastVisiblePosition); + return getPlaybackSet(playablePositions, firstVisiblePosition, lastVisiblePosition); } - private @Nullable GiphyMp4PlaybackRange getPlaybackRange(int firstVisiblePosition, int lastVisiblePosition) { - int distance = lastVisiblePosition - firstVisiblePosition; - - if (maxSimultaneousPlayback == 0) { - return null; - } - - if (distance <= maxSimultaneousPlayback) { - return new GiphyMp4PlaybackRange(firstVisiblePosition, lastVisiblePosition); - } else { - int center = (distance / 2) + firstVisiblePosition; - if (maxSimultaneousPlayback == 1) { - return new GiphyMp4PlaybackRange(center, center); - } else { - int first = Math.max(center - maxSimultaneousPlayback / 2, firstVisiblePosition); - int last = Math.min(first + maxSimultaneousPlayback, lastVisiblePosition); - return new GiphyMp4PlaybackRange(first, last); - } - } + private @NonNull Set getPlaybackSet(@NonNull Set playablePositions, int firstVisiblePosition, int lastVisiblePosition) { + return Stream.rangeClosed(firstVisiblePosition, lastVisiblePosition) + .sorted(new RangeComparator(firstVisiblePosition, lastVisiblePosition)) + .filter(playablePositions::contains) + .limit(maxSimultaneousPlayback) + .collect(Collectors.toSet()); } private static int[] findFirstVisibleItemPositions(@NonNull RecyclerView.LayoutManager layoutManager) { @@ -128,6 +127,31 @@ final class GiphyMp4AdapterPlaybackController extends RecyclerView.OnScrollListe } interface Callback { - void update(@NonNull List holders, @NonNull GiphyMp4PlaybackRange range); + void update(@NonNull RecyclerView recyclerView, @NonNull List holders, @NonNull Set playbackSet); + } + + @VisibleForTesting + static final class RangeComparator implements Comparator { + + private final int center; + + RangeComparator(int firstVisiblePosition, int lastVisiblePosition) { + int delta = lastVisiblePosition - firstVisiblePosition; + + center = firstVisiblePosition + (delta / 2); + } + + @Override + public int compare(Integer o1, Integer o2) { + int distance1 = Math.abs(o1 - center); + int distance2 = Math.abs(o2 - center); + int comp = Integer.compare(distance1, distance2); + + if (comp == 0) { + return Integer.compare(o1, o2); + } + + return comp; + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicy.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicy.java index f84143e537..92805ab75c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicy.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicy.java @@ -4,11 +4,9 @@ import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; import com.google.android.exoplayer2.util.MimeTypes; -import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.util.DeviceProperties; import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.MediaUtil; import java.util.concurrent.TimeUnit; @@ -17,28 +15,44 @@ import java.util.concurrent.TimeUnit; */ public final class GiphyMp4PlaybackPolicy { + private static final int MAXIMUM_SUPPORTED_PLAYBACK_PRE_23 = 6; + private static final int MAXIMUM_SUPPORTED_PLAYBACK_PRE_23_LOW_MEM = 3; + private static final float SEARCH_RESULT_RATIO = 0.75f; + private GiphyMp4PlaybackPolicy() { } public static boolean sendAsMp4() { return FeatureFlags.mp4GifSendSupport(); } + public static boolean autoplay() { + return !DeviceProperties.isLowMemoryDevice(ApplicationDependencies.getApplication()); + } + public static int maxRepeatsOfSinglePlayback() { - return 3; + return 4; } public static long maxDurationOfSinglePlayback() { - return TimeUnit.SECONDS.toMillis(6); + return TimeUnit.SECONDS.toMillis(8); + } + + public static int maxSimultaneousPlaybackInConversation() { + return maxSimultaneousPlaybackWithRatio(1f - SEARCH_RESULT_RATIO); } public static int maxSimultaneousPlaybackInSearchResults() { + return maxSimultaneousPlaybackWithRatio(SEARCH_RESULT_RATIO); + } + + private static int maxSimultaneousPlaybackWithRatio(float ratio) { int maxInstances = 0; try { MediaCodecInfo info = MediaCodecUtil.getDecoderInfo(MimeTypes.VIDEO_H264, false); - if (info != null) { - maxInstances = (int) (info.getMaxSupportedInstances() * 0.75f); + if (info != null && info.getMaxSupportedInstances() > 0) { + maxInstances = (int) (info.getMaxSupportedInstances() * ratio); } } catch (MediaCodecUtil.DecoderQueryException ignored) { @@ -49,9 +63,9 @@ public final class GiphyMp4PlaybackPolicy { } if (DeviceProperties.isLowMemoryDevice(ApplicationDependencies.getApplication())) { - return 2; + return (int) (MAXIMUM_SUPPORTED_PLAYBACK_PRE_23_LOW_MEM * ratio); } else { - return 6; + return (int) (MAXIMUM_SUPPORTED_PLAYBACK_PRE_23 * ratio); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicyEnforcer.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicyEnforcer.java new file mode 100644 index 0000000000..10c7a9cc52 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicyEnforcer.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.giph.mp4; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +/** + * Enforces a video player to play back a specified number of loops given + * video length and device policy. + */ +public final class GiphyMp4PlaybackPolicyEnforcer { + + private final Callback callback; + private final long maxDurationOfSinglePlayback; + private final long maxRepeatsOfSinglePlayback; + + private long loopsRemaining = -1; + + public GiphyMp4PlaybackPolicyEnforcer(@NonNull Callback callback) { + this(callback, + GiphyMp4PlaybackPolicy.maxDurationOfSinglePlayback(), + GiphyMp4PlaybackPolicy.maxRepeatsOfSinglePlayback()); + } + + @VisibleForTesting + GiphyMp4PlaybackPolicyEnforcer(@NonNull Callback callback, + long maxDurationOfSinglePlayback, + long maxRepeatsOfSinglePlayback) + { + this.callback = callback; + this.maxDurationOfSinglePlayback = maxDurationOfSinglePlayback; + this.maxRepeatsOfSinglePlayback = maxRepeatsOfSinglePlayback; + } + + void setMediaDuration(long duration) { + long maxLoopsByDuration = Math.max(1, maxDurationOfSinglePlayback / duration); + + loopsRemaining = Math.min(maxLoopsByDuration, maxRepeatsOfSinglePlayback); + } + + public boolean endPlayback() { + if (loopsRemaining < 0) throw new IllegalStateException("Must call setMediaDuration before calling this method."); + else if (loopsRemaining == 0) return true; + else { + loopsRemaining--; + if (loopsRemaining == 0) { + callback.onPlaybackWillEnd(); + return true; + } else { + return false; + } + } + } + + + public interface Callback { + void onPlaybackWillEnd(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackRange.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackRange.java deleted file mode 100644 index 5dd812bbbc..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackRange.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.thoughtcrime.securesms.giph.mp4; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.Objects; - -/** - * Object describing the range of adapter positions for which playback should begin. - */ -final class GiphyMp4PlaybackRange { - private final int startPosition; - private final int endPosition; - - GiphyMp4PlaybackRange(int startPosition, int endPosition) { - this.startPosition = startPosition; - this.endPosition = endPosition; - } - - boolean shouldPlayVideo(int adapterPosition) { - if (adapterPosition == RecyclerView.NO_POSITION) return false; - - return this.startPosition <= adapterPosition && this.endPosition > adapterPosition; - } - - @Override - public @NonNull String toString() { - return "PlaybackRange{" + - "startPosition=" + startPosition + - ", endPosition=" + endPosition + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - final GiphyMp4PlaybackRange that = (GiphyMp4PlaybackRange) o; - return startPosition == that.startPosition && - endPosition == that.endPosition; - } - - @Override public int hashCode() { - return Objects.hash(startPosition, endPosition); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlayerHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlayerHolder.java deleted file mode 100644 index de3c827f95..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlayerHolder.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.thoughtcrime.securesms.giph.mp4; - -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.source.MediaSource; - -/** - * Object which holds on to an injected video player. - */ -final class GiphyMp4PlayerHolder implements Player.EventListener { - private final FrameLayout container; - private final GiphyMp4VideoPlayer player; - - private Runnable onPlaybackReady; - private MediaSource mediaSource; - - GiphyMp4PlayerHolder(@NonNull FrameLayout container, @NonNull GiphyMp4VideoPlayer player) { - this.container = container; - this.player = player; - } - - @NonNull FrameLayout getContainer() { - return container; - } - - public void setMediaSource(@Nullable MediaSource mediaSource) { - this.mediaSource = mediaSource; - - if (mediaSource != null) { - player.setVideoSource(mediaSource); - player.play(); - } else { - player.stop(); - } - } - - public @Nullable MediaSource getMediaSource() { - return mediaSource; - } - - void setOnPlaybackReady(@Nullable Runnable onPlaybackReady) { - this.onPlaybackReady = onPlaybackReady; - } - - @Override - public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if (playbackState == Player.STATE_READY) { - if (onPlaybackReady != null) { - onPlaybackReady.run(); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Projection.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Projection.java new file mode 100644 index 0000000000..3a36ad3f0f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Projection.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.giph.mp4; + +import android.graphics.Rect; +import android.view.View; +import android.view.ViewParent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.components.CornerMask; + +/** + * Describes the position and size of the area where a video should play. + */ +public final class GiphyMp4Projection { + + private final float x; + private final float y; + private final int width; + private final int height; + private final CornerMask cornerMask; + + public GiphyMp4Projection(float x, float y, int width, int height, @Nullable CornerMask cornerMask) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.cornerMask = cornerMask; + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public @Nullable CornerMask getCornerMask() { + return cornerMask; + } + + public @NonNull GiphyMp4Projection translateX(float xTranslation) { + return new GiphyMp4Projection(x + xTranslation, y, width, height, cornerMask); + } + + public static @NonNull GiphyMp4Projection forView(@NonNull RecyclerView recyclerView, @NonNull View view, @Nullable CornerMask cornerMask) { + Rect viewBounds = new Rect(); + + view.getDrawingRect(viewBounds); + recyclerView.offsetDescendantRectToMyCoords(view, viewBounds); + return new GiphyMp4Projection(viewBounds.left, viewBounds.top, view.getWidth(), view.getHeight(), cornerMask); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java new file mode 100644 index 0000000000..60d05b36b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.giph.mp4; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; + +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; + +import org.signal.glide.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.CornerMask; + +import java.util.ArrayList; +import java.util.List; + +/** + * Object which holds on to an injected video player. + */ +public final class GiphyMp4ProjectionPlayerHolder implements Player.EventListener { + private final FrameLayout container; + private final GiphyMp4VideoPlayer player; + + private Runnable onPlaybackReady; + private MediaSource mediaSource; + private GiphyMp4PlaybackPolicyEnforcer policyEnforcer; + + private GiphyMp4ProjectionPlayerHolder(@NonNull FrameLayout container, @NonNull GiphyMp4VideoPlayer player) { + this.container = container; + this.player = player; + } + + @NonNull FrameLayout getContainer() { + return container; + } + + public void playContent(@NonNull MediaSource mediaSource, @Nullable GiphyMp4PlaybackPolicyEnforcer policyEnforcer) { + this.mediaSource = mediaSource; + this.policyEnforcer = policyEnforcer; + + player.setVideoSource(mediaSource); + player.play(); + } + + public void clearMedia() { + this.mediaSource = null; + this.policyEnforcer = null; + player.stop(); + } + + public @Nullable MediaSource getMediaSource() { + return mediaSource; + } + + public void setOnPlaybackReady(@Nullable Runnable onPlaybackReady) { + this.onPlaybackReady = onPlaybackReady; + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (playbackState == Player.STATE_READY) { + if (onPlaybackReady != null) { + if (policyEnforcer != null) { + policyEnforcer.setMediaDuration(player.getDuration()); + } + onPlaybackReady.run(); + } + } + } + + @Override + public void onPositionDiscontinuity(int reason) { + if (policyEnforcer != null && reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) { + if (policyEnforcer.endPlayback()) { + player.stop(); + } + } + } + + public static @NonNull List injectVideoViews(@NonNull Context context, + @NonNull Lifecycle lifecycle, + @NonNull ViewGroup viewGroup, + int nPlayers) + { + List holders = new ArrayList<>(nPlayers); + GiphyMp4ExoPlayerProvider playerProvider = new GiphyMp4ExoPlayerProvider(context); + + for (int i = 0; i < nPlayers; i++) { + FrameLayout container = (FrameLayout) LayoutInflater.from(context) + .inflate(R.layout.giphy_mp4_player, viewGroup, false); + GiphyMp4VideoPlayer player = container.findViewById(R.id.video_player); + ExoPlayer exoPlayer = playerProvider.create(); + GiphyMp4ProjectionPlayerHolder holder = new GiphyMp4ProjectionPlayerHolder(container, player); + + lifecycle.addObserver(player); + player.setExoPlayer(exoPlayer); + player.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FILL); + exoPlayer.addListener(holder); + + holders.add(holder); + viewGroup.addView(container); + } + + return holders; + } + + public void setCornerMask(@Nullable CornerMask cornerMask) { + player.setCornerMask(cornerMask); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java new file mode 100644 index 0000000000..9034757667 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java @@ -0,0 +1,137 @@ +package org.thoughtcrime.securesms.giph.mp4; + +import android.util.SparseArray; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Logic for updating content and positioning of videos as the user scrolls the list of gifs. + */ +public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackController.Callback, GiphyMp4DisplayUpdater { + + private final List holders; + private final SparseArray playing; + private final SparseArray notPlaying; + + public GiphyMp4ProjectionRecycler(@NonNull List holders) { + this.holders = holders; + this.playing = new SparseArray<>(holders.size()); + this.notPlaying = new SparseArray<>(holders.size()); + } + + @Override + public void update(@NonNull RecyclerView recyclerView, + @NonNull List holders, + @NonNull Set playbackSet) + { + stopAndReleaseAssignedVideos(playbackSet); + + for (final GiphyMp4Playable holder : holders) { + if (playbackSet.contains(holder.getAdapterPosition())) { + startPlayback(acquireHolderForPosition(holder.getAdapterPosition()), holder); + } else { + holder.showProjectionArea(); + } + } + + for (final GiphyMp4Playable holder : holders) { + updateDisplay(recyclerView, holder); + } + } + + @Override + public void updateDisplay(@NonNull RecyclerView recyclerView, @NonNull GiphyMp4Playable holder) { + GiphyMp4ProjectionPlayerHolder playerHolder = getCurrentHolder(holder.getAdapterPosition()); + if (playerHolder != null) { + updateDisplay(recyclerView, playerHolder, holder); + } + } + + public @Nullable View getVideoPlayerAtAdapterPosition(int adapterPosition) { + GiphyMp4ProjectionPlayerHolder holder = getCurrentHolder(adapterPosition); + + if (holder != null) return holder.getContainer(); + else return null; + } + + private void stopAndReleaseAssignedVideos(@NonNull Set playbackSet) { + List markedForDeletion = new ArrayList<>(playing.size()); + for (int i = 0; i < playing.size(); i++) { + if (!playbackSet.contains(playing.keyAt(i))) { + notPlaying.put(playing.keyAt(i), playing.valueAt(i)); + playing.valueAt(i).clearMedia(); + playing.valueAt(i).setOnPlaybackReady(null); + markedForDeletion.add(playing.keyAt(i)); + } + } + + for (final Integer key : markedForDeletion) { + playing.remove(key); + } + } + + private void updateDisplay(@NonNull RecyclerView recyclerView, @NonNull GiphyMp4ProjectionPlayerHolder holder, @NonNull GiphyMp4Playable giphyMp4Playable) { + GiphyMp4Projection projection = giphyMp4Playable.getProjection(recyclerView); + + holder.getContainer().setX(projection.getX()); + holder.getContainer().setY(projection.getY()); + + ViewGroup.LayoutParams params = holder.getContainer().getLayoutParams(); + if (params.width != projection.getWidth() || params.height != projection.getHeight()) { + params.width = projection.getWidth(); + params.height = projection.getHeight(); + holder.getContainer().setLayoutParams(params); + } + + holder.setCornerMask(projection.getCornerMask()); + } + + private void startPlayback(@NonNull GiphyMp4ProjectionPlayerHolder holder, @NonNull GiphyMp4Playable giphyMp4Playable) { + if (!Objects.equals(holder.getMediaSource(), giphyMp4Playable.getMediaSource())) { + holder.setOnPlaybackReady(null); + giphyMp4Playable.showProjectionArea(); + + holder.setOnPlaybackReady(giphyMp4Playable::hideProjectionArea); + holder.playContent(giphyMp4Playable.getMediaSource(), giphyMp4Playable.getPlaybackPolicyEnforcer()); + } + } + + private @Nullable GiphyMp4ProjectionPlayerHolder getCurrentHolder(int adapterPosition) { + if (playing.get(adapterPosition) != null) { + return playing.get(adapterPosition); + } else if (notPlaying.get(adapterPosition) != null) { + return notPlaying.get(adapterPosition); + } else { + return null; + } + } + + private @NonNull GiphyMp4ProjectionPlayerHolder acquireHolderForPosition(int adapterPosition) { + GiphyMp4ProjectionPlayerHolder holder = playing.get(adapterPosition); + if (holder == null) { + if (notPlaying.size() != 0) { + holder = notPlaying.get(adapterPosition); + if (holder == null) { + int key = notPlaying.keyAt(0); + holder = Objects.requireNonNull(notPlaying.get(key)); + notPlaying.remove(key); + } else { + notPlaying.remove(adapterPosition); + } + } else { + holder = holders.remove(0); + } + playing.put(adapterPosition, holder); + } + return holder; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java index c1e4bbdd90..4c95d7f466 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java @@ -1,14 +1,17 @@ package org.thoughtcrime.securesms.giph.mp4; import android.content.Context; +import android.graphics.Canvas; import android.graphics.Color; import android.util.AttributeSet; import android.widget.FrameLayout; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; @@ -16,6 +19,7 @@ import com.google.android.exoplayer2.ui.PlayerView; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.CornerMask; /** * Video Player class specifically created for the GiphyMp4Fragment. @@ -27,6 +31,7 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif private final PlayerView exoView; private ExoPlayer exoPlayer; + private CornerMask cornerMask; public GiphyMp4VideoPlayer(Context context) { this(context, null); @@ -49,7 +54,15 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif Log.d(TAG, "onDetachedFromWindow"); super.onDetachedFromWindow(); } - + + @Override protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + + if (cornerMask != null) { + cornerMask.mask(canvas); + } + } + void setExoPlayer(@NonNull ExoPlayer exoPlayer) { exoView.setPlayer(exoPlayer); this.exoPlayer = exoPlayer; @@ -58,6 +71,11 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif void setVideoSource(@NonNull MediaSource mediaSource) { exoPlayer.prepare(mediaSource); } + + void setCornerMask(@Nullable CornerMask cornerMask) { + this.cornerMask = new CornerMask(this, cornerMask); + invalidate(); + } void play() { if (exoPlayer != null) { @@ -71,6 +89,14 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif } } + long getDuration() { + if (exoPlayer != null) { + return exoPlayer.getDuration(); + } else { + return C.LENGTH_UNSET; + } + } + void setResizeMode(@AspectRatioFrameLayout.ResizeMode int resizeMode) { exoView.setResizeMode(resizeMode); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java index c6c42f0723..007ca2d2d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java @@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.util.Util; /** * Holds a view which will either play back an MP4 gif or show its still. */ -final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder { +final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable { private final AspectRatioFrameLayout container; private final ImageView stillImage; @@ -62,14 +62,31 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder { itemView.setOnClickListener(v -> listener.onClick(giphyImage)); } - void show() { + @Override + public void showProjectionArea() { container.setAlpha(1f); } - void hide() { + @Override + public void hideProjectionArea() { container.setAlpha(0f); } + @Override + public @NonNull MediaSource getMediaSource() { + return mediaSource; + } + + @Override + public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) { + return GiphyMp4Projection.forView(recyclerView, itemView, null); + } + + @Override + public boolean canPlayContent() { + return true; + } + private void loadPlaceholderImage(@NonNull GiphyImage giphyImage) { GlideApp.with(itemView) .load(new ChunkedImageUrl(giphyImage.getStillUrl())) @@ -78,8 +95,4 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder { .transition(DrawableTransitionOptions.withCrossFade()) .into(stillImage); } - - @NonNull MediaSource getMediaSource() { - return mediaSource; - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java index c84178466b..b743f40c89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java @@ -5,6 +5,7 @@ import android.content.Intent; import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.view.MenuItem; +import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.lifecycle.ViewModelProviders; @@ -15,6 +16,9 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler; import org.thoughtcrime.securesms.messagedetails.MessageDetailsAdapter.MessageDetailsViewState; import org.thoughtcrime.securesms.messagedetails.MessageDetailsViewModel.Factory; import org.thoughtcrime.securesms.mms.GlideApp; @@ -65,6 +69,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { initializeList(); initializeViewModel(); initializeActionBar(); + initializeVideoPlayer(); } @Override @@ -113,6 +118,15 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { }); } + private void initializeVideoPlayer() { + FrameLayout videoContainer = findViewById(R.id.video_container); + RecyclerView recyclerView = findViewById(R.id.message_details_list); + List holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(this, getLifecycle(), videoContainer, 1); + GiphyMp4ProjectionRecycler callback = new GiphyMp4ProjectionRecycler(holders); + + GiphyMp4PlaybackController.attach(recyclerView, callback, 1); + } + private void initializeActionBar() { requireSupportActionBar().setDisplayHomeAsUpEnabled(true); requireSupportActionBar().setTitle(R.string.AndroidManifest__message_details); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java index 2ddcd363b4..d8c271e5cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java @@ -3,7 +3,11 @@ package org.thoughtcrime.securesms.messagedetails; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.drawable.ColorDrawable; import android.view.View; +import android.view.ViewGroup; import android.view.ViewStub; import android.widget.TextView; @@ -12,16 +16,24 @@ import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.exoplayer2.source.MediaSource; + import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.conversation.ConversationItem; import org.thoughtcrime.securesms.conversation.ConversationMessage; +import org.thoughtcrime.securesms.conversation.MaskDrawable; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer; +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import org.whispersystems.libsignal.util.guava.Optional; import java.sql.Date; @@ -29,7 +41,7 @@ import java.text.SimpleDateFormat; import java.util.HashSet; import java.util.Locale; -final class MessageHeaderViewHolder extends RecyclerView.ViewHolder { +final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable { private final TextView sentDate; private final TextView receivedDate; private final TextView expiresIn; @@ -42,6 +54,7 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder { private final ViewStub updateStub; private final ViewStub sentStub; private final ViewStub receivedStub; + private final MaskDrawable maskDrawable; private GlideRequests glideRequests; private ConversationItem conversationItem; @@ -63,6 +76,9 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder { updateStub = itemView.findViewById(R.id.message_details_header_message_view_update); sentStub = itemView.findViewById(R.id.message_details_header_message_view_sent_multimedia); receivedStub = itemView.findViewById(R.id.message_details_header_message_view_received_multimedia); + + maskDrawable = new MaskDrawable(itemView.getBackground()); + itemView.setBackground(maskDrawable); } void bind(@NonNull LifecycleOwner lifecycleOwner, @Nullable ConversationMessage conversationMessage, boolean running) { @@ -88,7 +104,20 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder { conversationItem = (ConversationItem) receivedStub.inflate(); } } - conversationItem.bind(lifecycleOwner, conversationMessage, Optional.absent(), Optional.absent(), glideRequests, Locale.getDefault(), new HashSet<>(), conversationMessage.getMessageRecord().getRecipient(), null, false, false, false); + conversationItem.bind(lifecycleOwner, + conversationMessage, + Optional.absent(), + Optional.absent(), + glideRequests, + Locale.getDefault(), + new HashSet<>(), + conversationMessage.getMessageRecord().getRecipient(), + null, + false, + false, + false, + new AttachmentMediaSourceFactory(conversationItem.getContext()), + true); } private void bindErrorState(MessageRecord messageRecord) { @@ -181,6 +210,39 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder { ((ClipboardManager) itemView.getContext().getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", text)); } + @Override + public void showProjectionArea() { + conversationItem.showProjectionArea(); + maskDrawable.setMask(null); + } + + @Override + public void hideProjectionArea() { + conversationItem.hideProjectionArea(); + maskDrawable.setMask(conversationItem.getThumbnailMaskingRect((ViewGroup) itemView)); + maskDrawable.setCorners(conversationItem.getThumbnailCornerMask(itemView).getRadii()); + } + + @Override + public @Nullable MediaSource getMediaSource() { + return conversationItem.getMediaSource(); + } + + @Override + public @Nullable GiphyMp4PlaybackPolicyEnforcer getPlaybackPolicyEnforcer() { + return conversationItem.getPlaybackPolicyEnforcer(); + } + + @Override + public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerview) { + return conversationItem.getProjection(recyclerview); + } + + @Override public + boolean canPlayContent() { + return conversationItem.canPlayContent(); + } + private class ExpiresUpdater implements Runnable { private final long expireStartedTimestamp; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java index ec840e605b..9c2a792bdf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java @@ -22,7 +22,7 @@ public class GifSlide extends ImageSlide { } public GifSlide(Context context, Uri uri, long size, int width, int height, boolean borderless, @Nullable String caption) { - super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, true, null, caption, null, null, null, false, borderless, false, false)); + super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, true, null, caption, null, null, null, false, borderless, true, false)); this.borderless = borderless; } @@ -30,4 +30,9 @@ public class GifSlide extends ImageSlide { public boolean isBorderless() { return borderless; } + + @Override + public boolean isVideoGif() { + return true; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentMediaSourceFactory.java similarity index 55% rename from app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java rename to app/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentMediaSourceFactory.java index cdb6b36562..7b3cbf8501 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentMediaSourceFactory.java @@ -1,9 +1,10 @@ -package org.thoughtcrime.securesms.components.voice; +package org.thoughtcrime.securesms.video.exo; import android.content.Context; +import android.net.Uri; import android.support.v4.media.MediaDescriptionCompat; -import androidx.annotation.Nullable; +import androidx.annotation.NonNull; import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; import com.google.android.exoplayer2.extractor.ExtractorsFactory; @@ -11,17 +12,20 @@ import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory; - /** - * This class is responsible for creating a MediaSource object for a given MediaDescriptionCompat + * This class is responsible for creating a MediaSource object for a given Uri, using AttachmentDataSourceFactory */ -final class VoiceNoteMediaSourceFactory { +public final class AttachmentMediaSourceFactory { - private final Context context; + private final ExtractorMediaSource.Factory extractorMediaSourceFactory; - VoiceNoteMediaSourceFactory(Context context) { - this.context = context; + public AttachmentMediaSourceFactory(@NonNull Context context) { + DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null); + AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(context, defaultDataSourceFactory, null); + ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true); + + extractorMediaSourceFactory = new ExtractorMediaSource.Factory(attachmentDataSourceFactory) + .setExtractorsFactory(extractorsFactory); } /** @@ -31,13 +35,14 @@ final class VoiceNoteMediaSourceFactory { * * @return A preparable MediaSource */ - public @Nullable MediaSource createMediaSource(MediaDescriptionCompat description) { - DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null); - AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(context, defaultDataSourceFactory, null); - ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true); + public @NonNull MediaSource createMediaSource(MediaDescriptionCompat description) { + return createMediaSource(description.getMediaUri()); + } - return new ExtractorMediaSource.Factory(attachmentDataSourceFactory) - .setExtractorsFactory(extractorsFactory) - .createMediaSource(description.getMediaUri()); + /** + * Creates a MediaSource for a given Uri + */ + public @NonNull MediaSource createMediaSource(Uri uri) { + return extractorMediaSourceFactory.createMediaSource(uri); } } diff --git a/app/src/main/res/layout/conversation_fragment.xml b/app/src/main/res/layout/conversation_fragment.xml index 326eb06941..70d1f6af79 100644 --- a/app/src/main/res/layout/conversation_fragment.xml +++ b/app/src/main/res/layout/conversation_fragment.xml @@ -11,6 +11,15 @@ layout="@layout/conversation_item_banner" android:visibility="gone" /> + + diff --git a/app/src/main/res/layout/conversation_item_sent_thumbnail.xml b/app/src/main/res/layout/conversation_item_sent_thumbnail.xml index 4d5d0ada96..052c5713b8 100644 --- a/app/src/main/res/layout/conversation_item_sent_thumbnail.xml +++ b/app/src/main/res/layout/conversation_item_sent_thumbnail.xml @@ -17,5 +17,6 @@ app:conversationThumbnail_maxWidth="@dimen/media_bubble_max_width" app:conversationThumbnail_minHeight="@dimen/media_bubble_min_height" app:conversationThumbnail_maxHeight="@dimen/media_bubble_max_height" + app:conversationThumbnail_gifWidth="@dimen/media_bubble_gif_width" tools:src="@drawable/ic_video_light" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/message_details_activity.xml b/app/src/main/res/layout/message_details_activity.xml index b6d2b531f5..fcc3ac922f 100644 --- a/app/src/main/res/layout/message_details_activity.xml +++ b/app/src/main/res/layout/message_details_activity.xml @@ -1,8 +1,18 @@ - \ No newline at end of file + android:background="@drawable/preference_divider"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-sw360dp/dimens.xml b/app/src/main/res/values-sw360dp/dimens.xml index 572e8684ce..1a3bfe7291 100644 --- a/app/src/main/res/values-sw360dp/dimens.xml +++ b/app/src/main/res/values-sw360dp/dimens.xml @@ -14,4 +14,6 @@ 92dp 48dp + + 260dp \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 0032923647..a727dd3183 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -173,6 +173,7 @@ + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 95d83909d2..b9f6d3e560 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -47,6 +47,7 @@ 100dp 320dp 175dp + 240dp 242dp 175dp diff --git a/app/src/test/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackControllerRangeComparatorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackControllerRangeComparatorTest.kt new file mode 100644 index 0000000000..2f898471c5 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackControllerRangeComparatorTest.kt @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.giph.mp4 + +import org.junit.Assert +import org.junit.Test + +class GiphyMp4PlaybackControllerRangeComparatorTest { + @Test + fun `Given a range of numbers, when I sort with comparator, then I expect an array sorted from the center out`() { + val testSubject = createComparator(0, 10) + + val sorted = (0..10).sortedWith(testSubject).toIntArray() + val expected = intArrayOf(5, 4, 6, 3, 7, 2, 8, 1, 9, 0, 10) + + Assert.assertArrayEquals(expected, sorted) + } + + private fun createComparator(start: Int, end: Int): GiphyMp4PlaybackController.RangeComparator = + GiphyMp4PlaybackController.RangeComparator(start, end) +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicyEnforcerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicyEnforcerTest.kt new file mode 100644 index 0000000000..bcbbaa5331 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PlaybackPolicyEnforcerTest.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.giph.mp4 + +import org.junit.Assert +import org.junit.Test +import java.util.concurrent.TimeUnit + +class GiphyMp4PlaybackPolicyEnforcerTest { + + @Test + fun `Given a 1s video, when I have a max time of 8s and max repeats of 4, then I expect 4 loops`() { + val mediaDuration = TimeUnit.SECONDS.toMillis(1) + val maxDuration = TimeUnit.SECONDS.toMillis(8) + val maxRepeats = 4L + var ended = false + val testSubject = GiphyMp4PlaybackPolicyEnforcer({ ended = true }, maxDuration, maxRepeats) + + testSubject.setMediaDuration(mediaDuration) + + Assert.assertTrue((0..2).map { testSubject.endPlayback() }.all { !it }) + Assert.assertFalse(ended) + Assert.assertTrue(testSubject.endPlayback()) + Assert.assertTrue(ended) + } + + @Test + fun `Given a 3s video, when I have a max time of 8s and max repeats of 4, then I expect 2 loops`() { + val mediaDuration = TimeUnit.SECONDS.toMillis(3) + val maxDuration = TimeUnit.SECONDS.toMillis(8) + val maxRepeats = 4L + var ended = false + val testSubject = GiphyMp4PlaybackPolicyEnforcer({ ended = true }, maxDuration, maxRepeats) + + testSubject.setMediaDuration(mediaDuration) + + Assert.assertFalse(testSubject.endPlayback()) + Assert.assertFalse(ended) + Assert.assertTrue(testSubject.endPlayback()) + Assert.assertTrue(ended) + } + + @Test + fun `Given a 10s video, when I have a max time of 8s and max repeats of 4, then I expect 1 loop`() { + val mediaDuration = TimeUnit.SECONDS.toMillis(10) + val maxDuration = TimeUnit.SECONDS.toMillis(8) + val maxRepeats = 4L + var ended = false + val testSubject = GiphyMp4PlaybackPolicyEnforcer({ ended = true }, maxDuration, maxRepeats) + + testSubject.setMediaDuration(mediaDuration) + + Assert.assertTrue(testSubject.endPlayback()) + Assert.assertTrue(ended) + } +}