From c8fc2418b8f5458a817bba221d025b822225e130 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Fri, 2 Sep 2022 16:52:47 +0200 Subject: [PATCH] AccountMediaFragment improvements (#2684) * initial setup * add spacing between images * use blurhash * handle hidden state and show video indicator * handle item clicks * small cleanup * move SquareImageView into account.media package * fix build * improve AccountMediaGridAdapter * handle loadstate, errors and refreshing * remove commented out code * log error * show audio attachments with icon * fix glitchy transition animation * set image Description on imageview * show toast with media description on long press --- .../tusky/adapter/StatusBaseViewHolder.java | 27 +- .../components/account/AccountPagerAdapter.kt | 2 +- .../account/media/AccountMediaFragment.kt | 328 +++++------------- .../account/media/AccountMediaGridAdapter.kt | 126 +++++++ .../account/media/AccountMediaPagingSource.kt | 37 ++ .../media/AccountMediaRemoteMediator.kt | 80 +++++ .../account/media/AccountMediaViewModel.kt | 64 ++++ .../media/GridSpacingItemDecoration.kt | 47 +++ .../account/media}/SquareImageView.kt | 2 +- .../tusky/di/ViewModelFactory.kt | 6 + .../tusky/network/MastodonApi.kt | 12 +- .../tusky/util/AttachmentHelper.kt | 26 ++ .../tusky/viewdata/AttachmentViewData.kt | 34 +- .../main/res/layout/item_account_media.xml | 18 + app/src/main/res/values/dimens.xml | 5 + 15 files changed, 530 insertions(+), 284 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/account/media/GridSpacingItemDecoration.kt rename app/src/main/java/com/keylesspalace/tusky/{view => components/account/media}/SquareImageView.kt (92%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt create mode 100644 app/src/main/res/layout/item_account_media.xml 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 980f644b..51b61fac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -29,7 +29,6 @@ import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestBuilder; -import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.bitmap.CenterCrop; import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners; import com.google.android.material.button.MaterialButton; @@ -44,6 +43,7 @@ import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; +import com.keylesspalace.tusky.util.AttachmentHelper; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; @@ -563,7 +563,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (i < attachments.size()) { Attachment attachment = attachments.get(i); mediaLabel.setVisibility(View.VISIBLE); - mediaDescriptions[i] = getAttachmentDescription(context, attachment); + mediaDescriptions[i] = AttachmentHelper.getFormattedDescription(attachment, context); updateMediaLabel(i, sensitive, showingContent); // Set the icon next to the label. @@ -590,24 +590,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } }); view.setOnLongClickListener(v -> { - CharSequence description = getAttachmentDescription(view.getContext(), attachment); + CharSequence description = AttachmentHelper.getFormattedDescription(attachment, view.getContext()); Toast.makeText(view.getContext(), description, Toast.LENGTH_LONG).show(); return true; }); } - private static CharSequence getAttachmentDescription(Context context, Attachment attachment) { - String duration = ""; - if (attachment.getMeta() != null && attachment.getMeta().getDuration() != null && attachment.getMeta().getDuration() > 0) { - duration = formatDuration(attachment.getMeta().getDuration()) + " "; - } - if (TextUtils.isEmpty(attachment.getDescription())) { - return duration + context.getString(R.string.description_post_media_no_description_placeholder); - } else { - return duration + attachment.getDescription(); - } - } - protected void hideSensitiveMediaWarning() { sensitiveMediaWarning.setVisibility(View.GONE); sensitiveMediaShow.setVisibility(View.GONE); @@ -1168,13 +1156,4 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { bookmarkButton.setVisibility(visibility); moreButton.setVisibility(visibility); } - - private static String formatDuration(double durationInSeconds) { - int seconds = (int) Math.round(durationInSeconds) % 60; - int minutes = (int) durationInSeconds % 3600 / 60; - int hours = (int) durationInSeconds / 3600; - - return String.format("%d:%02d:%02d", hours, minutes, seconds); - } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt index 760db829..baeeea43 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt @@ -35,7 +35,7 @@ class AccountPagerAdapter( 0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false) 1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false) 2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false) - 3 -> AccountMediaFragment.newInstance(accountId, false) + 3 -> AccountMediaFragment.newInstance(accountId) else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index 57876d85..69d651d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2017 Andrew Dawson +/* Copyright 2022 Tusky Contributors * * This file is a part of Tusky. * @@ -15,41 +15,35 @@ package com.keylesspalace.tusky.components.account.media -import android.graphics.Color import android.os.Bundle import android.util.Log import android.view.View -import android.view.ViewGroup -import android.widget.ImageView import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import autodispose2.androidx.lifecycle.autoDispose -import com.bumptech.glide.Glide import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentTimelineBinding +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.RefreshableFragment -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.view.SquareImageView +import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.AttachmentViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.SingleObserver -import io.reactivex.rxjava3.disposables.Disposable -import retrofit2.Response +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import java.io.IOException -import java.util.Random import javax.inject.Inject /** @@ -58,192 +52,98 @@ import javax.inject.Inject * Fragment with multiple columns of media previews for the specified account. */ -class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFragment, Injectable { +class AccountMediaFragment : + Fragment(R.layout.fragment_timeline), + RefreshableFragment, + Injectable { @Inject - lateinit var api: MastodonApi + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var accountManager: AccountManager private val binding by viewBinding(FragmentTimelineBinding::bind) - private lateinit var accountId: String + private val viewModel: AccountMediaViewModel by viewModels { viewModelFactory } - private val adapter = MediaGridAdapter() - private val statuses = mutableListOf() - private var fetchingStatus = FetchingStatus.NOT_FETCHING - - private var isSwipeToRefreshEnabled: Boolean = true - private var needToRefresh = false - - private val callback = object : SingleObserver>> { - override fun onError(t: Throwable) { - fetchingStatus = FetchingStatus.NOT_FETCHING - - if (isAdded) { - binding.swipeRefreshLayout.isRefreshing = false - binding.progressBar.visibility = View.GONE - binding.topProgressBar.hide() - binding.statusView.show() - if (t is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { - doInitialLoadingIfNeeded() - } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { - doInitialLoadingIfNeeded() - } - } - } - - Log.d(TAG, "Failed to fetch account media", t) - } - - override fun onSuccess(response: Response>) { - fetchingStatus = FetchingStatus.NOT_FETCHING - if (isAdded) { - binding.swipeRefreshLayout.isRefreshing = false - binding.progressBar.visibility = View.GONE - binding.topProgressBar.hide() - - val body = response.body() - body?.let { fetched -> - statuses.addAll(0, fetched) - // flatMap requires iterable but I don't want to box each array into list - val result = mutableListOf() - for (status in fetched) { - result.addAll(AttachmentViewData.list(status)) - } - adapter.addTop(result) - if (result.isNotEmpty()) - binding.recyclerView.scrollToPosition(0) - - if (statuses.isEmpty()) { - binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) - } - } - } - } - - override fun onSubscribe(d: Disposable) {} - } - - private val bottomCallback = object : SingleObserver>> { - override fun onError(t: Throwable) { - fetchingStatus = FetchingStatus.NOT_FETCHING - - Log.d(TAG, "Failed to fetch account media", t) - } - - override fun onSuccess(response: Response>) { - fetchingStatus = FetchingStatus.NOT_FETCHING - val body = response.body() - body?.let { fetched -> - Log.d(TAG, "fetched ${fetched.size} statuses") - if (fetched.isNotEmpty()) Log.d(TAG, "first: ${fetched.first().id}, last: ${fetched.last().id}") - statuses.addAll(fetched) - Log.d(TAG, "now there are ${statuses.size} statuses") - // flatMap requires iterable but I don't want to box each array into list - val result = mutableListOf() - for (status in fetched) { - result.addAll(AttachmentViewData.list(status)) - } - adapter.addBottom(result) - } - } - - override fun onSubscribe(d: Disposable) { } - } + private lateinit var adapter: AccountMediaGridAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) == true - accountId = arguments?.getString(ACCOUNT_ID_ARG)!! + viewModel.accountId = arguments?.getString(ACCOUNT_ID_ARG)!! } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + + val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia + + val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) + val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) + + adapter = AccountMediaGridAdapter( + alwaysShowSensitiveMedia = alwaysShowSensitiveMedia, + useBlurhash = useBlurhash, + context = view.context, + onAttachmentClickListener = ::onAttachmentClick + ) val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count) - val layoutManager = GridLayoutManager(view.context, columnCount) + val imageSpacing = view.context.resources.getDimensionPixelSize(R.dimen.profile_media_spacing) - adapter.baseItemColor = ThemeUtils.getColor(view.context, android.R.attr.windowBackground) + binding.recyclerView.addItemDecoration(GridSpacingItemDecoration(columnCount, imageSpacing, 0)) - binding.recyclerView.layoutManager = layoutManager + binding.recyclerView.layoutManager = GridLayoutManager(view.context, columnCount) binding.recyclerView.adapter = adapter - if (isSwipeToRefreshEnabled) { - binding.swipeRefreshLayout.setOnRefreshListener { - refresh() - } - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - } + binding.swipeRefreshLayout.isEnabled = false + binding.statusView.visibility = View.GONE - binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - - override fun onScrolled(recycler_view: RecyclerView, dx: Int, dy: Int) { - if (dy > 0) { - val itemCount = layoutManager.itemCount - val lastItem = layoutManager.findLastCompletelyVisibleItemPosition() - if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) { - statuses.lastOrNull()?.let { (id) -> - Log.d(TAG, "Requesting statuses with max_id: $id, (bottom)") - fetchingStatus = FetchingStatus.FETCHING_BOTTOM - api.accountStatuses(accountId, id, null, null, null, true, null) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) - .subscribe(bottomCallback) - } - } - } + viewLifecycleOwner.lifecycleScope.launch { + viewModel.media.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + + adapter.addLoadStateListener { loadState -> + binding.progressBar.visible(loadState.refresh == LoadState.Loading && adapter.itemCount == 0) + + if (loadState.refresh is LoadState.Error) { + binding.recyclerView.hide() + binding.statusView.show() + val errorState = loadState.refresh as LoadState.Error + if (errorState.error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { adapter.retry() } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { adapter.retry() } + } + Log.w(TAG, "error loading account media", errorState.error) + } else { + binding.recyclerView.show() + binding.statusView.hide() } - }) - - doInitialLoadingIfNeeded() - } - - private fun refresh() { - binding.statusView.hide() - if (fetchingStatus != FetchingStatus.NOT_FETCHING) return - if (statuses.isEmpty()) { - fetchingStatus = FetchingStatus.INITIAL_FETCHING - api.accountStatuses(accountId, null, null, null, null, true, null) - } else { - fetchingStatus = FetchingStatus.REFRESHING - api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null) - }.observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe(callback) - - if (!isSwipeToRefreshEnabled) - binding.topProgressBar.show() - } - - private fun doInitialLoadingIfNeeded() { - if (isAdded) { - binding.statusView.hide() } - if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) { - fetchingStatus = FetchingStatus.INITIAL_FETCHING - api.accountStatuses(accountId, null, null, null, null, true, null) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) - .subscribe(callback) - } else if (needToRefresh) - refresh() - needToRefresh = false } - private fun viewMedia(items: List, currentIndex: Int, view: View?) { + private fun onAttachmentClick(selected: AttachmentViewData, view: View) { + if (!selected.isRevealed) { + viewModel.revealAttachment(selected) + return + } + val attachmentsFromSameStatus = viewModel.attachmentData.filter { attachmentViewData -> + attachmentViewData.statusId == selected.statusId + } + val currentIndex = attachmentsFromSameStatus.indexOf(selected) - when (items[currentIndex].attachment.type) { + when (selected.attachment.type) { Attachment.Type.IMAGE, Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.AUDIO -> { - val intent = ViewMediaActivity.newIntent(context, items, currentIndex) - if (view != null && activity != null) { - val url = items[currentIndex].attachment.url + val intent = ViewMediaActivity.newIntent(context, attachmentsFromSameStatus, currentIndex) + if (activity != null) { + val url = selected.attachment.url ViewCompat.setTransitionName(view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url) startActivity(intent, options.toBundle()) @@ -252,96 +152,26 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr } } Attachment.Type.UNKNOWN -> { - context?.openLink(items[currentIndex].attachment.url) - } - } - } - - private enum class FetchingStatus { - NOT_FETCHING, INITIAL_FETCHING, FETCHING_BOTTOM, REFRESHING - } - - inner class MediaGridAdapter : - RecyclerView.Adapter() { - - var baseItemColor = Color.BLACK - - private val items = mutableListOf() - private val itemBgBaseHSV = FloatArray(3) - private val random = Random() - - fun addTop(newItems: List) { - items.addAll(0, newItems) - notifyItemRangeInserted(0, newItems.size) - } - - fun addBottom(newItems: List) { - if (newItems.isEmpty()) return - - val oldLen = items.size - items.addAll(newItems) - notifyItemRangeInserted(oldLen, newItems.size) - } - - override fun onAttachedToRecyclerView(recycler_view: RecyclerView) { - val hsv = FloatArray(3) - Color.colorToHSV(baseItemColor, hsv) - super.onAttachedToRecyclerView(recycler_view) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { - val view = SquareImageView(parent.context) - view.scaleType = ImageView.ScaleType.CENTER_CROP - return MediaViewHolder(view) - } - - override fun getItemCount(): Int = items.size - - override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { - itemBgBaseHSV[2] = random.nextFloat() * (1f - 0.3f) + 0.3f - holder.imageView.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV)) - val item = items[position] - - Glide.with(holder.imageView) - .load(item.attachment.previewUrl) - .centerInside() - .into(holder.imageView) - } - - inner class MediaViewHolder(val imageView: ImageView) : - RecyclerView.ViewHolder(imageView), - View.OnClickListener { - init { - itemView.setOnClickListener(this) - } - - // saving some allocations - override fun onClick(v: View?) { - viewMedia(items, bindingAdapterPosition, imageView) + context?.openLink(selected.attachment.url) } } } override fun refreshContent() { - if (isAdded) - refresh() - else - needToRefresh = true + adapter.refresh() } companion object { - @JvmStatic - fun newInstance(accountId: String, enableSwipeToRefresh: Boolean = true): AccountMediaFragment { + + fun newInstance(accountId: String): AccountMediaFragment { val fragment = AccountMediaFragment() - val args = Bundle() + val args = Bundle(1) args.putString(ACCOUNT_ID_ARG, accountId) - args.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) fragment.arguments = args return fragment } private const val ACCOUNT_ID_ARG = "account_id" private const val TAG = "AccountMediaFragment" - private const val ARG_ENABLE_SWIPE_TO_REFRESH = "arg.enable.swipe.to.refresh" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt new file mode 100644 index 00000000..e5a0b592 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt @@ -0,0 +1,126 @@ +package com.keylesspalace.tusky.components.account.media + +import android.content.Context +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.view.setPadding +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.decodeBlurHash +import com.keylesspalace.tusky.util.getFormattedDescription +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import java.util.Random + +class AccountMediaGridAdapter( + private val alwaysShowSensitiveMedia: Boolean, + private val useBlurhash: Boolean, + context: Context, + private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit +) : PagingDataAdapter>( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean { + return oldItem.attachment.id == newItem.attachment.id + } + + override fun areContentsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean { + return oldItem == newItem + } + } +) { + + private val baseItemBackgroundColor = ThemeUtils.getColor(context, R.attr.colorSurface) + private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator) + private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp) + + private val itemBgBaseHSV = FloatArray(3) + private val random = Random() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + val binding = ItemAccountMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false) + Color.colorToHSV(baseItemBackgroundColor, itemBgBaseHSV) + itemBgBaseHSV[2] = itemBgBaseHSV[2] + random.nextFloat() / 3f - 1f / 6f + binding.root.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV)) + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + val context = holder.binding.root.context + getItem(position)?.let { item -> + + val imageView = holder.binding.accountMediaImageView + val overlay = holder.binding.accountMediaImageViewOverlay + + val blurhash = item.attachment.blurhash + val placeholder = if (useBlurhash && blurhash != null) { + decodeBlurHash(context, blurhash) + } else { + null + } + + if (item.attachment.type == Attachment.Type.AUDIO) { + overlay.hide() + + imageView.setPadding(context.resources.getDimensionPixelSize(R.dimen.profile_media_audio_icon_padding)) + + Glide.with(imageView) + .load(R.drawable.ic_music_box_preview_24dp) + .centerInside() + .into(imageView) + + imageView.contentDescription = item.attachment.getFormattedDescription(context) + } else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) { + overlay.show() + overlay.setImageDrawable(mediaHiddenDrawable) + + imageView.setPadding(0) + + Glide.with(imageView) + .load(placeholder) + .centerInside() + .into(imageView) + + imageView.contentDescription = imageView.context.getString(R.string.post_media_hidden_title) + } else { + if (item.attachment.type == Attachment.Type.VIDEO || item.attachment.type == Attachment.Type.GIFV) { + overlay.show() + overlay.setImageDrawable(videoIndicator) + } else { + overlay.hide() + } + + imageView.setPadding(0) + + Glide.with(imageView) + .asBitmap() + .load(item.attachment.previewUrl) + .placeholder(placeholder) + .centerInside() + .into(imageView) + + imageView.contentDescription = item.attachment.getFormattedDescription(context) + } + + holder.binding.root.setOnClickListener { + onAttachmentClickListener(item, imageView) + } + + holder.binding.root.setOnLongClickListener { view -> + val description = item.attachment.getFormattedDescription(view.context) + Toast.makeText(view.context, description, Toast.LENGTH_LONG).show() + true + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt new file mode 100644 index 00000000..60c76743 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt @@ -0,0 +1,37 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.account.media + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.viewdata.AttachmentViewData + +class AccountMediaPagingSource( + private val viewModel: AccountMediaViewModel +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + + return if (params is LoadParams.Refresh) { + val list = viewModel.attachmentData.toList() + LoadResult.Page(list, null, list.lastOrNull()?.statusId) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt new file mode 100644 index 00000000..734745f9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt @@ -0,0 +1,80 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.account.media + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException + +@OptIn(ExperimentalPagingApi::class) +class AccountMediaRemoteMediator( + private val api: MastodonApi, + private val viewModel: AccountMediaViewModel +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + + try { + val statusResponse = when (loadType) { + LoadType.REFRESH -> { + api.accountStatuses(viewModel.accountId, onlyMedia = true).await() + } + LoadType.PREPEND -> { + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + val maxId = state.lastItemOrNull()?.statusId + if (maxId != null) { + api.accountStatuses(viewModel.accountId, maxId = maxId, onlyMedia = true).await() + } else { + return MediatorResult.Success(endOfPaginationReached = false) + } + } + } + + val statuses = statusResponse.body() + if (!statusResponse.isSuccessful || statuses == null) { + return MediatorResult.Error(HttpException(statusResponse)) + } + + val attachments = statuses.flatMap { status -> + AttachmentViewData.list(status) + } + + if (loadType == LoadType.REFRESH) { + viewModel.attachmentData.clear() + } + + viewModel.attachmentData.addAll(attachments) + + viewModel.currentSource?.invalidate() + return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) + } catch (e: Exception) { + return ifExpected(e) { + MediatorResult.Error(e) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt new file mode 100644 index 00000000..5c3528e9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt @@ -0,0 +1,64 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.account.media + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import javax.inject.Inject + +class AccountMediaViewModel @Inject constructor ( + api: MastodonApi +) : ViewModel() { + + lateinit var accountId: String + + val attachmentData: MutableList = mutableListOf() + + var currentSource: AccountMediaPagingSource? = null + + @OptIn(ExperimentalPagingApi::class) + val media = Pager( + config = PagingConfig( + pageSize = LOAD_AT_ONCE, + prefetchDistance = LOAD_AT_ONCE * 2 + ), + pagingSourceFactory = { + AccountMediaPagingSource( + viewModel = this + ).also { source -> + currentSource = source + } + }, + remoteMediator = AccountMediaRemoteMediator(api, this) + ).flow + .cachedIn(viewModelScope) + + fun revealAttachment(viewData: AttachmentViewData) { + val position = attachmentData.indexOfFirst { oldViewData -> oldViewData.id == viewData.id } + attachmentData[position] = viewData.copy(isRevealed = true) + currentSource?.invalidate() + } + + companion object { + private const val LOAD_AT_ONCE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/GridSpacingItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/GridSpacingItemDecoration.kt new file mode 100644 index 00000000..34ad159e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/GridSpacingItemDecoration.kt @@ -0,0 +1,47 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.account.media + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +class GridSpacingItemDecoration( + private val spanCount: Int, + private val spacing: Int, + private val topOffset: Int +) : ItemDecoration() { + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildAdapterPosition(view) // item position + if (position < topOffset) return + + val column = (position - topOffset) % spanCount // item column + + outRect.left = column * spacing / spanCount // column * ((1f / spanCount) * spacing) + outRect.right = + spacing - (column + 1) * spacing / spanCount // spacing - (column + 1) * ((1f / spanCount) * spacing) + if (position - topOffset >= spanCount) { + outRect.top = spacing // item top + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/SquareImageView.kt similarity index 92% rename from app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt rename to app/src/main/java/com/keylesspalace/tusky/components/account/media/SquareImageView.kt index d7e753bb..b696bfc1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/SquareImageView.kt @@ -1,4 +1,4 @@ -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.account.media import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 05444b5a..ef6eb2af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -5,6 +5,7 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.keylesspalace.tusky.components.account.AccountViewModel +import com.keylesspalace.tusky.components.account.media.AccountMediaViewModel import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel @@ -113,5 +114,10 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(ViewThreadViewModel::class) internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(AccountMediaViewModel::class) + internal abstract fun accountMediaViewModel(viewModel: AccountMediaViewModel): ViewModel // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index cb07545c..50d090f4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -319,12 +319,12 @@ interface MastodonApi { @GET("api/v1/accounts/{id}/statuses") fun accountStatuses( @Path("id") accountId: String, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("exclude_replies") excludeReplies: Boolean?, - @Query("only_media") onlyMedia: Boolean?, - @Query("pinned") pinned: Boolean? + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("limit") limit: Int? = null, + @Query("exclude_replies") excludeReplies: Boolean? = null, + @Query("only_media") onlyMedia: Boolean? = null, + @Query("pinned") pinned: Boolean? = null ): Single>> @GET("api/v1/accounts/{id}/followers") diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt new file mode 100644 index 00000000..6307e721 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt @@ -0,0 +1,26 @@ +@file:JvmName("AttachmentHelper") +package com.keylesspalace.tusky.util + +import android.content.Context +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Attachment +import kotlin.math.roundToInt + +fun Attachment.getFormattedDescription(context: Context): CharSequence { + var duration = "" + if (meta?.duration != null && meta.duration > 0) { + duration = formatDuration(meta.duration.toDouble()) + " " + } + return if (description.isNullOrEmpty()) { + duration + context.getString(R.string.description_post_media_no_description_placeholder) + } else { + duration + description + } +} + +private fun formatDuration(durationInSeconds: Double): String { + val seconds = durationInSeconds.roundToInt() % 60 + val minutes = durationInSeconds.toInt() % 3600 / 60 + val hours = durationInSeconds.toInt() / 3600 + return "%d:%02d:%02d".format(hours, minutes, seconds) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt index b0a8062f..ae24cebe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -1,22 +1,50 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + package com.keylesspalace.tusky.viewdata import android.os.Parcelable import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize data class AttachmentViewData( val attachment: Attachment, val statusId: String, - val statusUrl: String + val statusUrl: String, + val sensitive: Boolean, + val isRevealed: Boolean ) : Parcelable { + + @IgnoredOnParcel + val id = attachment.id + companion object { @JvmStatic fun list(status: Status): List { val actionable = status.actionableStatus - return actionable.attachments.map { - AttachmentViewData(it, actionable.id, actionable.url!!) + return actionable.attachments.map { attachment -> + AttachmentViewData( + attachment = attachment, + statusId = actionable.id, + statusUrl = actionable.url!!, + sensitive = actionable.sensitive, + isRevealed = !actionable.sensitive + ) } } } diff --git a/app/src/main/res/layout/item_account_media.xml b/app/src/main/res/layout/item_account_media.xml new file mode 100644 index 00000000..a2938b69 --- /dev/null +++ b/app/src/main/res/layout/item_account_media.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 5ec69307..ea2e744b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -53,4 +53,9 @@ 16dp 36dp + + 3dp + + 16dp +