Add support for inline video playback of gifs in Conversation.
This commit is contained in:
parent
32d79ead15
commit
281630e751
45 changed files with 1364 additions and 408 deletions
|
@ -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<MessageRecord> 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);
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<View> getAllTargets() {
|
||||
return Collections.singletonList(primaryTarget);
|
||||
}
|
||||
|
||||
protected void draw(@NonNull Canvas canvas) {
|
||||
primaryTarget.draw(canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
@ -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<ConversationMessage> selected;
|
||||
private final List<ConversationMessage> fastRecords;
|
||||
private final Set<Long> releasedFastRecords;
|
||||
private final Calendar calendar;
|
||||
private final MessageDigest digest;
|
||||
private final Set<ConversationMessage> selected;
|
||||
private final List<ConversationMessage> fastRecords;
|
||||
private final Set<Long> 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<ConversationMessage>() {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<GiphyMp4ProjectionPlayerHolder> 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() {
|
||||
|
|
|
@ -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<MessageRecord> previousRecord,
|
||||
@NonNull Optional<MessageRecord> nextRecord,
|
||||
boolean isGroupThread,
|
||||
boolean hasWallpaper,
|
||||
boolean messageRequestAccepted)
|
||||
private void setMediaAttributes(@NonNull MessageRecord messageRecord,
|
||||
@NonNull Optional<MessageRecord> previousRecord,
|
||||
@NonNull Optional<MessageRecord> 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);
|
||||
|
|
|
@ -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<Outliner> 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);
|
||||
|
|
|
@ -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<View> 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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<Recipient> recipientObserver;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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<GiphyMp4PlayerHolder> holders;
|
||||
private final SparseArray<GiphyMp4PlayerHolder> playing;
|
||||
private final SparseArray<GiphyMp4PlayerHolder> notPlaying;
|
||||
|
||||
GiphyMp4AdapterPlaybackControllerCallback(@NonNull List<GiphyMp4PlayerHolder> holders) {
|
||||
this.holders = holders;
|
||||
this.playing = new SparseArray<>(holders.size());
|
||||
this.notPlaying = new SparseArray<>(holders.size());
|
||||
}
|
||||
|
||||
@Override public void update(@NonNull List<GiphyMp4ViewHolder> 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<Integer> 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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<GiphyMp4PlayerHolder> holders = injectVideoViews(frameLayout);
|
||||
GiphyMp4AdapterPlaybackControllerCallback callback = new GiphyMp4AdapterPlaybackControllerCallback(holders);
|
||||
List<GiphyMp4ProjectionPlayerHolder> 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<GiphyMp4PlayerHolder> injectVideoViews(@NonNull ViewGroup viewGroup) {
|
||||
int nPlayers = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInSearchResults();
|
||||
List<GiphyMp4PlayerHolder> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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.
|
||||
* <p>
|
||||
* 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<GiphyMp4Playable> playables = new LinkedList<>();
|
||||
Set<Integer> 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<GiphyMp4ViewHolder> 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<Integer> playbackSet = getPlaybackSetForMaximumDistance(playablePositions, firstVisiblePositions, lastVisiblePositions);
|
||||
|
||||
callback.update(recyclerView, playables, playbackSet);
|
||||
}
|
||||
|
||||
private @Nullable GiphyMp4PlaybackRange getPlaybackRangeForMaximumDistance(int[] firstVisiblePositions, int[] lastVisiblePositions) {
|
||||
private @NonNull Set<Integer> getPlaybackSetForMaximumDistance(@NonNull Set<Integer> 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<Integer> getPlaybackSet(@NonNull Set<Integer> 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<GiphyMp4ViewHolder> holders, @NonNull GiphyMp4PlaybackRange range);
|
||||
void update(@NonNull RecyclerView recyclerView, @NonNull List<GiphyMp4Playable> holders, @NonNull Set<Integer> playbackSet);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static final class RangeComparator implements Comparator<Integer> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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<GiphyMp4ProjectionPlayerHolder> injectVideoViews(@NonNull Context context,
|
||||
@NonNull Lifecycle lifecycle,
|
||||
@NonNull ViewGroup viewGroup,
|
||||
int nPlayers)
|
||||
{
|
||||
List<GiphyMp4ProjectionPlayerHolder> 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);
|
||||
}
|
||||
}
|
|
@ -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<GiphyMp4ProjectionPlayerHolder> holders;
|
||||
private final SparseArray<GiphyMp4ProjectionPlayerHolder> playing;
|
||||
private final SparseArray<GiphyMp4ProjectionPlayerHolder> notPlaying;
|
||||
|
||||
public GiphyMp4ProjectionRecycler(@NonNull List<GiphyMp4ProjectionPlayerHolder> 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<GiphyMp4Playable> holders,
|
||||
@NonNull Set<Integer> 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<Integer> playbackSet) {
|
||||
List<Integer> 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<GiphyMp4ProjectionPlayerHolder> 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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -11,6 +11,15 @@
|
|||
layout="@layout/conversation_item_banner"
|
||||
android:visibility="gone" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/video_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="@android:id/list"
|
||||
app:layout_constraintEnd_toEndOf="@android:id/list"
|
||||
app:layout_constraintStart_toStartOf="@android:id/list"
|
||||
app:layout_constraintTop_toTopOf="@android:id/list" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@android:id/list"
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -15,5 +15,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="gone" />
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -1,8 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/message_details_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/preference_divider"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||
android:background="@drawable/preference_divider">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/video_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/message_details_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||
</FrameLayout>
|
|
@ -14,4 +14,6 @@
|
|||
|
||||
<dimen name="payment_recovery_phrase_adapter_margin">92dp</dimen>
|
||||
<dimen name="payment_recovery_phrase_outline_margin">48dp</dimen>
|
||||
|
||||
<dimen name="media_bubble_gif_width">260dp</dimen>
|
||||
</resources>
|
|
@ -173,6 +173,7 @@
|
|||
<attr name="conversationThumbnail_maxWidth" format="dimension" />
|
||||
<attr name="conversationThumbnail_minHeight" format="dimension" />
|
||||
<attr name="conversationThumbnail_maxHeight" format="dimension" />
|
||||
<attr name="conversationThumbnail_gifWidth" format="dimension" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="TypingIndicatorView">
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
<dimen name="media_bubble_min_height">100dp</dimen>
|
||||
<dimen name="media_bubble_max_height">320dp</dimen>
|
||||
<dimen name="media_bubble_sticker_dimens">175dp</dimen>
|
||||
<dimen name="media_bubble_gif_width">240dp</dimen>
|
||||
<dimen name="message_audio_width">242dp</dimen>
|
||||
|
||||
<dimen name="media_picker_folder_width">175dp</dimen>
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue