mirror of
https://github.com/pachli/pachli-android.git
synced 2025-02-03 02:37:37 +01:00
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:
parent
257f3a5c1c
commit
c8fc2418b8
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
|
@ -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)
|
||||||
|
}
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
18
app/src/main/res/layout/item_account_media.xml
Normal file
18
app/src/main/res/layout/item_account_media.xml
Normal 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>
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user