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
This commit is contained in:
Konrad Pozniak 2022-09-02 16:52:47 +02:00 committed by GitHub
parent 257f3a5c1c
commit c8fc2418b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 530 additions and 284 deletions

View File

@ -29,7 +29,6 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder; 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.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners; import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners;
import com.google.android.material.button.MaterialButton; 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.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.AttachmentHelper;
import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -563,7 +563,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (i < attachments.size()) { if (i < attachments.size()) {
Attachment attachment = attachments.get(i); Attachment attachment = attachments.get(i);
mediaLabel.setVisibility(View.VISIBLE); mediaLabel.setVisibility(View.VISIBLE);
mediaDescriptions[i] = getAttachmentDescription(context, attachment); mediaDescriptions[i] = AttachmentHelper.getFormattedDescription(attachment, context);
updateMediaLabel(i, sensitive, showingContent); updateMediaLabel(i, sensitive, showingContent);
// Set the icon next to the label. // Set the icon next to the label.
@ -590,24 +590,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
}); });
view.setOnLongClickListener(v -> { 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(); Toast.makeText(view.getContext(), description, Toast.LENGTH_LONG).show();
return true; 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() { protected void hideSensitiveMediaWarning() {
sensitiveMediaWarning.setVisibility(View.GONE); sensitiveMediaWarning.setVisibility(View.GONE);
sensitiveMediaShow.setVisibility(View.GONE); sensitiveMediaShow.setVisibility(View.GONE);
@ -1168,13 +1156,4 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
bookmarkButton.setVisibility(visibility); bookmarkButton.setVisibility(visibility);
moreButton.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);
}
} }

View File

@ -35,7 +35,7 @@ class AccountPagerAdapter(
0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false) 0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false)
1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false) 1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false)
2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, 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") else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")
} }
} }

View File

@ -1,4 +1,4 @@
/* Copyright 2017 Andrew Dawson /* Copyright 2022 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -15,41 +15,35 @@
package com.keylesspalace.tusky.components.account.media package com.keylesspalace.tusky.components.account.media
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment 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.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.R
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding 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 com.keylesspalace.tusky.viewdata.AttachmentViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.collectLatest
import io.reactivex.rxjava3.core.SingleObserver import kotlinx.coroutines.launch
import io.reactivex.rxjava3.disposables.Disposable
import retrofit2.Response
import java.io.IOException import java.io.IOException
import java.util.Random
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -58,192 +52,98 @@ import javax.inject.Inject
* Fragment with multiple columns of media previews for the specified account. * 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 @Inject
lateinit var api: MastodonApi lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var accountManager: AccountManager
private val binding by viewBinding(FragmentTimelineBinding::bind) private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var accountId: String private val viewModel: AccountMediaViewModel by viewModels { viewModelFactory }
private val adapter = MediaGridAdapter() private lateinit var adapter: AccountMediaGridAdapter
private val statuses = mutableListOf<Status>()
private var fetchingStatus = FetchingStatus.NOT_FETCHING
private var isSwipeToRefreshEnabled: Boolean = true
private var needToRefresh = false
private val callback = object : SingleObserver<Response<List<Status>>> {
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<List<Status>>) {
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<AttachmentViewData>()
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<Response<List<Status>>> {
override fun onError(t: Throwable) {
fetchingStatus = FetchingStatus.NOT_FETCHING
Log.d(TAG, "Failed to fetch account media", t)
}
override fun onSuccess(response: Response<List<Status>>) {
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<AttachmentViewData>()
for (status in fetched) {
result.addAll(AttachmentViewData.list(status))
}
adapter.addBottom(result)
}
}
override fun onSubscribe(d: Disposable) { }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) == true viewModel.accountId = arguments?.getString(ACCOUNT_ID_ARG)!!
accountId = arguments?.getString(ACCOUNT_ID_ARG)!!
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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 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 binding.recyclerView.adapter = adapter
if (isSwipeToRefreshEnabled) { binding.swipeRefreshLayout.isEnabled = false
binding.swipeRefreshLayout.setOnRefreshListener {
refresh()
}
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
binding.statusView.visibility = View.GONE binding.statusView.visibility = View.GONE
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { viewLifecycleOwner.lifecycleScope.launch {
viewModel.media.collectLatest { pagingData ->
override fun onScrolled(recycler_view: RecyclerView, dx: Int, dy: Int) { adapter.submitData(pagingData)
if (dy > 0) { }
val itemCount = layoutManager.itemCount }
val lastItem = layoutManager.findLastCompletelyVisibleItemPosition()
if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) { adapter.addLoadStateListener { loadState ->
statuses.lastOrNull()?.let { (id) -> binding.progressBar.visible(loadState.refresh == LoadState.Loading && adapter.itemCount == 0)
Log.d(TAG, "Requesting statuses with max_id: $id, (bottom)")
fetchingStatus = FetchingStatus.FETCHING_BOTTOM if (loadState.refresh is LoadState.Error) {
api.accountStatuses(accountId, id, null, null, null, true, null) binding.recyclerView.hide()
.observeOn(AndroidSchedulers.mainThread()) binding.statusView.show()
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) val errorState = loadState.refresh as LoadState.Error
.subscribe(bottomCallback) 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<AttachmentViewData>, 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.IMAGE,
Attachment.Type.GIFV, Attachment.Type.GIFV,
Attachment.Type.VIDEO, Attachment.Type.VIDEO,
Attachment.Type.AUDIO -> { Attachment.Type.AUDIO -> {
val intent = ViewMediaActivity.newIntent(context, items, currentIndex) val intent = ViewMediaActivity.newIntent(context, attachmentsFromSameStatus, currentIndex)
if (view != null && activity != null) { if (activity != null) {
val url = items[currentIndex].attachment.url val url = selected.attachment.url
ViewCompat.setTransitionName(view, url) ViewCompat.setTransitionName(view, url)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url)
startActivity(intent, options.toBundle()) startActivity(intent, options.toBundle())
@ -252,96 +152,26 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
} }
} }
Attachment.Type.UNKNOWN -> { Attachment.Type.UNKNOWN -> {
context?.openLink(items[currentIndex].attachment.url) context?.openLink(selected.attachment.url)
}
}
}
private enum class FetchingStatus {
NOT_FETCHING, INITIAL_FETCHING, FETCHING_BOTTOM, REFRESHING
}
inner class MediaGridAdapter :
RecyclerView.Adapter<MediaGridAdapter.MediaViewHolder>() {
var baseItemColor = Color.BLACK
private val items = mutableListOf<AttachmentViewData>()
private val itemBgBaseHSV = FloatArray(3)
private val random = Random()
fun addTop(newItems: List<AttachmentViewData>) {
items.addAll(0, newItems)
notifyItemRangeInserted(0, newItems.size)
}
fun addBottom(newItems: List<AttachmentViewData>) {
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)
} }
} }
} }
override fun refreshContent() { override fun refreshContent() {
if (isAdded) adapter.refresh()
refresh()
else
needToRefresh = true
} }
companion object { companion object {
@JvmStatic
fun newInstance(accountId: String, enableSwipeToRefresh: Boolean = true): AccountMediaFragment { fun newInstance(accountId: String): AccountMediaFragment {
val fragment = AccountMediaFragment() val fragment = AccountMediaFragment()
val args = Bundle() val args = Bundle(1)
args.putString(ACCOUNT_ID_ARG, accountId) args.putString(ACCOUNT_ID_ARG, accountId)
args.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh)
fragment.arguments = args fragment.arguments = args
return fragment return fragment
} }
private const val ACCOUNT_ID_ARG = "account_id" private const val ACCOUNT_ID_ARG = "account_id"
private const val TAG = "AccountMediaFragment" private const val TAG = "AccountMediaFragment"
private const val ARG_ENABLE_SWIPE_TO_REFRESH = "arg.enable.swipe.to.refresh"
} }
} }

View File

@ -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<AttachmentViewData, BindingHolder<ItemAccountMediaBinding>>(
object : DiffUtil.ItemCallback<AttachmentViewData>() {
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<ItemAccountMediaBinding> {
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<ItemAccountMediaBinding>, 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
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<String, AttachmentViewData>() {
override fun getRefreshKey(state: PagingState<String, AttachmentViewData>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, AttachmentViewData> {
return if (params is LoadParams.Refresh) {
val list = viewModel.attachmentData.toList()
LoadResult.Page(list, null, list.lastOrNull()?.statusId)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<String, AttachmentViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, AttachmentViewData>
): 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)
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<AttachmentViewData> = 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
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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
}
}
}

View File

@ -1,4 +1,4 @@
package com.keylesspalace.tusky.view package com.keylesspalace.tusky.components.account.media
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet

View File

@ -5,6 +5,7 @@ package com.keylesspalace.tusky.di
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.components.account.AccountViewModel 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.announcements.AnnouncementsViewModel
import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
@ -113,5 +114,10 @@ abstract class ViewModelModule {
@IntoMap @IntoMap
@ViewModelKey(ViewThreadViewModel::class) @ViewModelKey(ViewThreadViewModel::class)
internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(AccountMediaViewModel::class)
internal abstract fun accountMediaViewModel(viewModel: AccountMediaViewModel): ViewModel
// Add more ViewModels here // Add more ViewModels here
} }

View File

@ -319,12 +319,12 @@ interface MastodonApi {
@GET("api/v1/accounts/{id}/statuses") @GET("api/v1/accounts/{id}/statuses")
fun accountStatuses( fun accountStatuses(
@Path("id") accountId: String, @Path("id") accountId: String,
@Query("max_id") maxId: String?, @Query("max_id") maxId: String? = null,
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String? = null,
@Query("limit") limit: Int?, @Query("limit") limit: Int? = null,
@Query("exclude_replies") excludeReplies: Boolean?, @Query("exclude_replies") excludeReplies: Boolean? = null,
@Query("only_media") onlyMedia: Boolean?, @Query("only_media") onlyMedia: Boolean? = null,
@Query("pinned") pinned: Boolean? @Query("pinned") pinned: Boolean? = null
): Single<Response<List<Status>>> ): Single<Response<List<Status>>>
@GET("api/v1/accounts/{id}/followers") @GET("api/v1/accounts/{id}/followers")

View File

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

View File

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.viewdata package com.keylesspalace.tusky.viewdata
import android.os.Parcelable import android.os.Parcelable
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class AttachmentViewData( data class AttachmentViewData(
val attachment: Attachment, val attachment: Attachment,
val statusId: String, val statusId: String,
val statusUrl: String val statusUrl: String,
val sensitive: Boolean,
val isRevealed: Boolean
) : Parcelable { ) : Parcelable {
@IgnoredOnParcel
val id = attachment.id
companion object { companion object {
@JvmStatic @JvmStatic
fun list(status: Status): List<AttachmentViewData> { fun list(status: Status): List<AttachmentViewData> {
val actionable = status.actionableStatus val actionable = status.actionableStatus
return actionable.attachments.map { return actionable.attachments.map { attachment ->
AttachmentViewData(it, actionable.id, actionable.url!!) AttachmentViewData(
attachment = attachment,
statusId = actionable.id,
statusUrl = actionable.url!!,
sensitive = actionable.sensitive,
isRevealed = !actionable.sensitive
)
} }
} }
} }

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.keylesspalace.tusky.components.account.media.SquareImageView
android:id="@+id/accountMediaImageView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:scaleType="centerCrop" />
<ImageView
android:id="@+id/accountMediaImageViewOverlay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>

View File

@ -53,4 +53,9 @@
<dimen name="fabMargin">16dp</dimen> <dimen name="fabMargin">16dp</dimen>
<dimen name="avatar_toolbar_nav_icon_size">36dp</dimen> <dimen name="avatar_toolbar_nav_icon_size">36dp</dimen>
<dimen name="profile_media_spacing">3dp</dimen>
<dimen name="profile_media_audio_icon_padding">16dp</dimen>
</resources> </resources>