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.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);
}
}

View File

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

View File

@ -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<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) { }
}
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<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.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<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)
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"
}
}

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.util.AttributeSet

View File

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

View File

@ -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<Response<List<Status>>>
@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
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<AttachmentViewData> {
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
)
}
}
}

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="avatar_toolbar_nav_icon_size">36dp</dimen>
<dimen name="profile_media_spacing">3dp</dimen>
<dimen name="profile_media_audio_icon_padding">16dp</dimen>
</resources>