diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 69765da68..9a7776627 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -1,5 +1,7 @@ package com.keylesspalace.tusky.adapter; +import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; + import android.content.Context; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; @@ -23,6 +25,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; +import androidx.core.view.ViewKt; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -53,6 +56,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.view.MediaPreviewImageView; +import com.keylesspalace.tusky.view.MediaPreviewLayout; import com.keylesspalace.tusky.viewdata.PollOptionViewData; import com.keylesspalace.tusky.viewdata.PollViewData; import com.keylesspalace.tusky.viewdata.PollViewDataKt; @@ -66,12 +70,11 @@ import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; import kotlin.collections.CollectionsKt; -import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; - public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public static class Key { public static final String KEY_CREATED = "created"; } + private TextView displayName; private TextView username; private ImageButton replyButton; @@ -81,8 +84,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private SparkButton bookmarkButton; private ImageButton moreButton; private ConstraintLayout mediaContainer; - protected MediaPreviewImageView[] mediaPreviews; - private ImageView[] mediaOverlays; + protected MediaPreviewLayout mediaPreview; private TextView sensitiveMediaWarning; private View sensitiveMediaShow; protected TextView[] mediaLabels; @@ -132,19 +134,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { mediaContainer = itemView.findViewById(R.id.status_media_preview_container); mediaContainer.setClipToOutline(true); + mediaPreview = itemView.findViewById(R.id.status_media_preview); - mediaPreviews = new MediaPreviewImageView[]{ - itemView.findViewById(R.id.status_media_preview_0), - itemView.findViewById(R.id.status_media_preview_1), - itemView.findViewById(R.id.status_media_preview_2), - itemView.findViewById(R.id.status_media_preview_3) - }; - mediaOverlays = new ImageView[]{ - itemView.findViewById(R.id.status_media_overlay_0), - itemView.findViewById(R.id.status_media_overlay_1), - itemView.findViewById(R.id.status_media_overlay_2), - itemView.findViewById(R.id.status_media_overlay_3) - }; sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning); sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button); mediaLabels = new TextView[]{ @@ -181,8 +172,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { mediaPreviewUnloaded = new ColorDrawable(ThemeUtils.getColor(itemView.getContext(), R.attr.colorBackgroundAccent)); } - protected abstract int getMediaPreviewHeight(Context context); - protected void setDisplayName(String name, List customEmojis, StatusDisplayOptions statusDisplayOptions) { CharSequence emojifiedName = CustomEmojiHelper.emojify( name, customEmojis, displayName, statusDisplayOptions.animateEmojis() @@ -420,64 +409,51 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Drawable placeholder = blurhash != null ? decodeBlurHash(blurhash) : mediaPreviewUnloaded; - if (TextUtils.isEmpty(previewUrl)) { - imageView.removeFocalPoint(); - - Glide.with(imageView) - .load(placeholder) - .centerInside() - .into(imageView); - - } else { - Focus focus = meta != null ? meta.getFocus() : null; - - if (focus != null) { // If there is a focal point for this attachment: - imageView.setFocalPoint(focus); - - Glide.with(imageView) - .load(previewUrl) - .placeholder(placeholder) - .centerInside() - .addListener(imageView) - .into(imageView); - } else { + ViewKt.doOnLayout(imageView, view -> { + if (TextUtils.isEmpty(previewUrl)) { imageView.removeFocalPoint(); Glide.with(imageView) - .load(previewUrl) - .placeholder(placeholder) + .load(placeholder) .centerInside() .into(imageView); + + } else { + Focus focus = meta != null ? meta.getFocus() : null; + + if (focus != null) { // If there is a focal point for this attachment: + imageView.setFocalPoint(focus); + + Glide.with(imageView) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(imageView) + .into(imageView); + } else { + imageView.removeFocalPoint(); + + Glide.with(imageView) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .into(imageView); + } } - } + return null; + }); } protected void setMediaPreviews(final List attachments, boolean sensitive, final StatusActionListener listener, boolean showingContent, boolean useBlurhash) { - Context context = itemView.getContext(); - final int n = Math.min(attachments.size(), Status.MAX_MEDIA_ATTACHMENTS); + mediaPreview.setAspectRatios(AttachmentHelper.aspectRatios(attachments)); - final int mediaPreviewHeight = getMediaPreviewHeight(context); - - if (n <= 2) { - mediaPreviews[0].getLayoutParams().height = mediaPreviewHeight * 2; - mediaPreviews[1].getLayoutParams().height = mediaPreviewHeight * 2; - } else { - mediaPreviews[0].getLayoutParams().height = mediaPreviewHeight; - mediaPreviews[1].getLayoutParams().height = mediaPreviewHeight; - mediaPreviews[2].getLayoutParams().height = mediaPreviewHeight; - mediaPreviews[3].getLayoutParams().height = mediaPreviewHeight; - } - - for (int i = 0; i < n; i++) { + mediaPreview.forEachIndexed((i, imageView) -> { Attachment attachment = attachments.get(i); String previewUrl = attachment.getPreviewUrl(); String description = attachment.getDescription(); - MediaPreviewImageView imageView = mediaPreviews[i]; - - imageView.setVisibility(View.VISIBLE); if (TextUtils.isEmpty(description)) { imageView.setContentDescription(imageView.getContext() @@ -495,42 +471,38 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { final Attachment.Type type = attachment.getType(); if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) { - mediaOverlays[i].setVisibility(View.VISIBLE); + imageView.setForeground(ContextCompat.getDrawable(itemView.getContext(), R.drawable.play_indicator_overlay)); } else { - mediaOverlays[i].setVisibility(View.GONE); + imageView.setForeground(null); } setAttachmentClickListener(imageView, listener, i, attachment, true); - } - if (sensitive) { - sensitiveMediaWarning.setText(R.string.post_sensitive_media_title); - } else { - sensitiveMediaWarning.setText(R.string.post_media_hidden_title); - } - - sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE); - sensitiveMediaShow.setVisibility(showingContent ? View.VISIBLE : View.GONE); - sensitiveMediaShow.setOnClickListener(v -> { - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - listener.onContentHiddenChange(false, getBindingAdapterPosition()); + if (sensitive) { + sensitiveMediaWarning.setText(R.string.post_sensitive_media_title); + } else { + sensitiveMediaWarning.setText(R.string.post_media_hidden_title); } - v.setVisibility(View.GONE); - sensitiveMediaWarning.setVisibility(View.VISIBLE); - }); - sensitiveMediaWarning.setOnClickListener(v -> { - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - listener.onContentHiddenChange(true, getBindingAdapterPosition()); - } - v.setVisibility(View.GONE); - sensitiveMediaShow.setVisibility(View.VISIBLE); - }); + sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE); + sensitiveMediaShow.setVisibility(showingContent ? View.VISIBLE : View.GONE); + sensitiveMediaShow.setOnClickListener(v -> { + if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onContentHiddenChange(false, getBindingAdapterPosition()); + } + v.setVisibility(View.GONE); + sensitiveMediaWarning.setVisibility(View.VISIBLE); + }); + sensitiveMediaWarning.setOnClickListener(v -> { + if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onContentHiddenChange(true, getBindingAdapterPosition()); + } + v.setVisibility(View.GONE); + sensitiveMediaShow.setVisibility(View.VISIBLE); + }); - // Hide any of the placeholder previews beyond the ones set. - for (int i = n; i < Status.MAX_MEDIA_ATTACHMENTS; i++) { - mediaPreviews[i].setVisibility(View.GONE); - } + return null; + }); } @DrawableRes @@ -751,10 +723,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { setMediaLabel(attachments, sensitive, listener, status.isShowingContent()); // Hide all unused views. - mediaPreviews[0].setVisibility(View.GONE); - mediaPreviews[1].setVisibility(View.GONE); - mediaPreviews[2].setVisibility(View.GONE); - mediaPreviews[3].setVisibility(View.GONE); + mediaPreview.setVisibility(View.GONE); hideSensitiveMediaWarning(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 74f09f641..2f74c1bc8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -1,6 +1,5 @@ package com.keylesspalace.tusky.adapter; -import android.content.Context; import android.graphics.drawable.Drawable; import android.text.method.LinkMovementMethod; import android.view.View; @@ -33,11 +32,6 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { infoDivider = view.findViewById(R.id.status_info_divider); } - @Override - protected int getMediaPreviewHeight(Context context) { - return context.getResources().getDimensionPixelSize(R.dimen.status_detail_media_preview_height); - } - @Override protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { if (createdAt == null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 93c475643..76d129170 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -53,11 +53,6 @@ public class StatusViewHolder extends StatusBaseViewHolder { contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); } - @Override - protected int getMediaPreviewHeight(Context context) { - return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); - } - @Override public void setupWithStatus(@NonNull StatusViewData.Concrete status, @NonNull final StatusActionListener listener, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 19280441e..3f362064b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -68,11 +68,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder { this.listener = listener; } - @Override - protected int getMediaPreviewHeight(Context context) { - return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); - } - void setupWithConversation( @NonNull ConversationViewData conversation, @Nullable Object payloads @@ -108,10 +103,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { } else { setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); // Hide all unused views. - mediaPreviews[0].setVisibility(View.GONE); - mediaPreviews[1].setVisibility(View.GONE); - mediaPreviews[2].setVisibility(View.GONE); - mediaPreviews[3].setVisibility(View.GONE); + mediaPreview.setVisibility(View.GONE); hideSensitiveMediaWarning(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index 27fdc8be6..c1368325e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -68,7 +68,9 @@ data class Attachment( @Parcelize data class MetaData( val focus: Focus?, - val duration: Float? + val duration: Float?, + val original: Size?, + val small: Size?, ) : Parcelable /** @@ -82,4 +84,14 @@ data class Attachment( val x: Float, val y: Float ) : Parcelable + + /** + * The size of an image, used to specify the width/height. + */ + @Parcelize + data class Size( + val width: Int, + val height: Int, + val aspect: Double + ) : Parcelable } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt index 6307e7211..f49b901a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt @@ -1,4 +1,5 @@ @file:JvmName("AttachmentHelper") + package com.keylesspalace.tusky.util import android.content.Context @@ -24,3 +25,12 @@ private fun formatDuration(durationInSeconds: Double): String { val hours = durationInSeconds.toInt() / 3600 return "%d:%02d:%02d".format(hours, minutes, seconds) } + +fun List.aspectRatios(): List { + return map { attachment -> + // clamp ratio between 2:1 & 1:2, defaulting to 16:9 + val size = (attachment.meta?.small ?: attachment.meta?.original) ?: return@map 1.7778 + val aspect = if (size.aspect > 0) size.aspect else size.width.toDouble() / size.height + aspect.coerceIn(0.5, 2.0) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt new file mode 100644 index 000000000..802141f7d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt @@ -0,0 +1,210 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import com.keylesspalace.tusky.R +import kotlin.math.roundToInt + +/** + * Lays out a set of [MediaPreviewImageView]s keeping their aspect ratios into account. + */ +class MediaPreviewLayout(context: Context, attrs: AttributeSet? = null) : + ViewGroup(context, attrs) { + + private val spacing = context.resources.getDimensionPixelOffset(R.dimen.preview_image_spacing) + + /** + * An ordered list of aspect ratios used for layout. An image view for each aspect ratio passed + * will be attached. Supports up to 4, additional ones will be ignored. + */ + var aspectRatios: List = emptyList() + set(value) { + field = value + attachImageViews() + } + + private val imageViewCache = Array(4) { MediaPreviewImageView(context) } + + private var measuredOrientation = LinearLayout.VERTICAL + + private fun attachImageViews() { + removeAllViews() + for (i in 0 until aspectRatios.size.coerceAtMost(imageViewCache.size)) { + addView(imageViewCache[i]) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val halfWidth = width / 2 - spacing / 2 + var totalHeight = 0 + + when (childCount) { + 1 -> { + val aspect = aspectRatios[0] + totalHeight += getChildAt(0).measureToAspect(width, aspect) + } + 2 -> { + val aspect1 = aspectRatios[0] + val aspect2 = aspectRatios[1] + + if ((aspect1 + aspect2) / 2 > 1.2) { + // stack vertically + measuredOrientation = LinearLayout.VERTICAL + totalHeight += getChildAt(0).measureToAspect(width, aspect1.coerceAtLeast(1.8)) + totalHeight += spacing + totalHeight += getChildAt(1).measureToAspect(width, aspect2.coerceAtLeast(1.8)) + } else { + // stack horizontally + measuredOrientation = LinearLayout.HORIZONTAL + val height = rowHeight(halfWidth, aspect1, aspect2) + totalHeight += height + getChildAt(0).measureExactly(halfWidth, height) + getChildAt(1).measureExactly(halfWidth, height) + } + } + 3 -> { + val aspect1 = aspectRatios[0] + val aspect2 = aspectRatios[1] + val aspect3 = aspectRatios[2] + if (aspect1 >= 1) { + // | 1 | + // ------------- + // | 2 | 3 | + measuredOrientation = LinearLayout.VERTICAL + totalHeight += getChildAt(0).measureToAspect(width, aspect1.coerceAtLeast(1.8)) + totalHeight += spacing + val bottomHeight = rowHeight(halfWidth, aspect2, aspect3) + totalHeight += bottomHeight + getChildAt(1).measureExactly(halfWidth, bottomHeight) + getChildAt(2).measureExactly(halfWidth, bottomHeight) + } else { + // | | 2 | + // | 1 |-----| + // | | 3 | + measuredOrientation = LinearLayout.HORIZONTAL + val colHeight = getChildAt(0).measureToAspect(halfWidth, aspect1) + totalHeight += colHeight + val halfHeight = colHeight / 2 - spacing / 2 + getChildAt(1).measureExactly(halfWidth, halfHeight) + getChildAt(2).measureExactly(halfWidth, halfHeight) + } + } + 4 -> { + val aspect1 = aspectRatios[0] + val aspect2 = aspectRatios[1] + val aspect3 = aspectRatios[2] + val aspect4 = aspectRatios[3] + val topHeight = rowHeight(halfWidth, aspect1, aspect2) + totalHeight += topHeight + getChildAt(0).measureExactly(halfWidth, topHeight) + getChildAt(1).measureExactly(halfWidth, topHeight) + totalHeight += spacing + val bottomHeight = rowHeight(halfWidth, aspect3, aspect4) + totalHeight += bottomHeight + getChildAt(2).measureExactly(halfWidth, bottomHeight) + getChildAt(3).measureExactly(halfWidth, bottomHeight) + } + } + + super.onMeasure( + widthMeasureSpec, + MeasureSpec.makeMeasureSpec(totalHeight, MeasureSpec.EXACTLY) + ) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val width = r - l + val height = b - t + val halfWidth = width / 2 - spacing / 2 + when (childCount) { + 1 -> { + getChildAt(0).layout(0, 0, width, height) + } + 2 -> { + if (measuredOrientation == LinearLayout.VERTICAL) { + val y = imageViewCache[0].measuredHeight + getChildAt(0).layout(0, 0, width, y) + getChildAt(1).layout( + 0, + y + spacing, + width, + y + spacing + getChildAt(1).measuredHeight + ) + } else { + getChildAt(0).layout(0, 0, halfWidth, height) + getChildAt(1).layout(halfWidth + spacing, 0, width, height) + } + } + 3 -> { + if (measuredOrientation == LinearLayout.VERTICAL) { + val y = getChildAt(0).measuredHeight + getChildAt(0).layout(0, 0, width, y) + getChildAt(1).layout(0, y + spacing, halfWidth, height) + getChildAt(2).layout(halfWidth + spacing, y + spacing, width, height) + } else { + val colHeight = getChildAt(0).measuredHeight + getChildAt(0).layout(0, 0, halfWidth, colHeight) + val halfHeight = colHeight / 2 - spacing / 2 + getChildAt(1).layout(halfWidth + spacing, 0, width, halfHeight) + getChildAt(2).layout( + halfWidth + spacing, + halfHeight + spacing, + width, + colHeight + ) + } + } + 4 -> { + val topHeight = (getChildAt(0).measuredHeight + getChildAt(1).measuredHeight) / 2 + getChildAt(0).layout(0, 0, halfWidth, topHeight) + getChildAt(1).layout(halfWidth + spacing, 0, width, topHeight) + val bottomHeight = + (imageViewCache[2].measuredHeight + imageViewCache[3].measuredHeight) / 2 + getChildAt(2).layout( + 0, + topHeight + spacing, + halfWidth, + topHeight + spacing + bottomHeight + ) + getChildAt(3).layout( + halfWidth + spacing, + topHeight + spacing, + width, + topHeight + spacing + bottomHeight + ) + } + } + } + + inline fun forEachIndexed(action: (Int, MediaPreviewImageView) -> Unit) { + for (index in 0 until childCount) { + action(index, getChildAt(index) as MediaPreviewImageView) + } + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + } +} + +private fun rowHeight(halfWidth: Int, aspect1: Double, aspect2: Double): Int { + return ((halfWidth / aspect1 + halfWidth / aspect2) / 2).roundToInt() +} + +private fun View.measureToAspect(width: Int, aspect: Double): Int { + val height = (width / aspect).roundToInt() + measureExactly(width, height) + return height +} + +private fun View.measureExactly(width: Int, height: Int) { + measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY) + ) +} diff --git a/app/src/main/res/drawable/play_indicator_overlay.xml b/app/src/main/res/drawable/play_indicator_overlay.xml new file mode 100644 index 000000000..66ffc2c9b --- /dev/null +++ b/app/src/main/res/drawable/play_indicator_overlay.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_media_preview.xml b/app/src/main/res/layout/item_media_preview.xml index 3d89335fc..34a0376ce 100644 --- a/app/src/main/res/layout/item_media_preview.xml +++ b/app/src/main/res/layout/item_media_preview.xml @@ -1,198 +1,113 @@ - + - + - + - + - + - + - - - - - - - - - - - - - - - + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index ea2e744b6..8ec84acf9 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -58,4 +58,5 @@ 16dp + 4dp