diff --git a/app/src/main/java/com/github/libretube/ui/adapters/CommentsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/CommentPagingAdapter.kt similarity index 72% rename from app/src/main/java/com/github/libretube/ui/adapters/CommentsAdapter.kt rename to app/src/main/java/com/github/libretube/ui/adapters/CommentPagingAdapter.kt index 4620cb584..2801aa1c8 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/CommentsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/CommentPagingAdapter.kt @@ -1,9 +1,8 @@ package com.github.libretube.ui.adapters -import android.annotation.SuppressLint import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams import android.widget.Toast import androidx.appcompat.content.res.AppCompatResources import androidx.core.os.bundleOf @@ -16,13 +15,13 @@ import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.commit import androidx.fragment.app.replace -import androidx.recyclerview.widget.RecyclerView +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil import com.github.libretube.R import com.github.libretube.api.obj.Comment import com.github.libretube.constants.IntentData import com.github.libretube.databinding.CommentsRowBinding import com.github.libretube.extensions.formatShort -import com.github.libretube.extensions.toID import com.github.libretube.helpers.ClipboardHelper import com.github.libretube.helpers.ImageHelper import com.github.libretube.helpers.NavigationHelper @@ -31,35 +30,18 @@ import com.github.libretube.ui.fragments.CommentsRepliesFragment import com.github.libretube.ui.viewholders.CommentsViewHolder import com.github.libretube.util.HtmlParser import com.github.libretube.util.LinkHandler -import com.github.libretube.util.TextUtils -class CommentsAdapter( +class CommentPagingAdapter( private val fragment: Fragment?, private val videoId: String, private val channelAvatar: String?, - private val comments: MutableList, - private val isRepliesAdapter: Boolean = false, + private val parentComment: Comment? = null, private val handleLink: ((url: String) -> Unit)?, private val dismiss: () -> Unit -) : RecyclerView.Adapter() { +) : PagingDataAdapter(CommentCallback) { + private val isRepliesAdapter = parentComment != null - fun clear() { - val size: Int = comments.size - comments.clear() - notifyItemRangeRemoved(0, size) - } - - fun updateItems(newItems: List) { - val commentsSize = comments.size - comments.addAll(newItems) - notifyItemRangeInserted(commentsSize, newItems.size) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentsViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = CommentsRowBinding.inflate(layoutInflater, parent, false) - return CommentsViewHolder(binding) - } + override fun getItemCount() = (if (isRepliesAdapter) 1 else 0) + super.getItemCount() private fun navigateToReplies(comment: Comment) { val args = bundleOf(IntentData.videoId to videoId, IntentData.comment to comment) @@ -69,20 +51,25 @@ class CommentsAdapter( } } - @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: CommentsViewHolder, position: Int) { - val comment = comments[position] + val comment = if (parentComment != null) { + if (position == 0) parentComment else getItem(position - 1)!! + } else { + getItem(position)!! + } holder.binding.apply { commentAuthor.text = comment.author commentAuthor.setBackgroundResource( if (comment.channelOwner) R.drawable.comment_channel_owner_bg else 0 ) - commentInfos.text = TextUtils.SEPARATOR + comment.commentedTime + commentInfos.text = root.context + .getString(R.string.commentedTimeWithSeparator, comment.commentedTime) commentText.movementMethod = LinkMovementMethodCompat.getInstance() commentText.text = comment.commentText?.replace("", " ") ?.parseAsHtml(tagHandler = HtmlParser(LinkHandler(handleLink ?: {}))) + commentorImage.setImageDrawable(null) ImageHelper.loadImage(comment.thumbnail, commentorImage, true) likesTextView.text = comment.likeCount.formatShort() @@ -91,24 +78,24 @@ class CommentsAdapter( creatorReplyImageView.isVisible = true } - if (comment.verified) verifiedImageView.isVisible = true - if (comment.pinned) pinnedImageView.isVisible = true - if (comment.hearted) heartedImageView.isVisible = true - if (comment.repliesPage != null) repliesCount.isVisible = true + verifiedImageView.isVisible = comment.verified + pinnedImageView.isVisible = comment.pinned + heartedImageView.isVisible = comment.hearted + repliesCount.isVisible = comment.repliesPage != null if (comment.replyCount > 0L) { repliesCount.text = comment.replyCount.formatShort() } commentorImage.setOnClickListener { NavigationHelper.navigateChannel(root.context, comment.commentorUrl) - dismiss.invoke() + dismiss() } if (isRepliesAdapter) { repliesCount.isGone = true // highlight the comment that is being replied to - if (comment == comments.firstOrNull()) { + if (position == 0) { root.setBackgroundColor( ThemeHelper.getThemeColor( root.context, @@ -117,7 +104,7 @@ class CommentsAdapter( ) root.updatePadding(top = 20) - root.updateLayoutParams { bottomMargin = 20 } + root.updateLayoutParams { bottomMargin = 20 } } else { root.background = AppCompatResources.getDrawable( root.context, @@ -127,12 +114,9 @@ class CommentsAdapter( } if (!isRepliesAdapter && comment.repliesPage != null) { - root.setOnClickListener { - navigateToReplies(comment) - } - commentText.setOnClickListener { - navigateToReplies(comment) - } + val onClickListener = View.OnClickListener { navigateToReplies(comment) } + root.setOnClickListener(onClickListener) + commentText.setOnClickListener(onClickListener) } root.setOnLongClickListener { ClipboardHelper.save( @@ -145,5 +129,17 @@ class CommentsAdapter( } } - override fun getItemCount() = comments.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentsViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = CommentsRowBinding.inflate(layoutInflater, parent, false) + return CommentsViewHolder(binding) + } +} + +private object CommentCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Comment, newItem: Comment): Boolean { + return oldItem.commentId == newItem.commentId + } + + override fun areContentsTheSame(oldItem: Comment, newItem: Comment) = true } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/CommentsMainFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/CommentsMainFragment.kt index 1e282cbf3..973fda531 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/CommentsMainFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/CommentsMainFragment.kt @@ -7,19 +7,22 @@ import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.LoadState import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.R import com.github.libretube.databinding.FragmentCommentsBinding import com.github.libretube.extensions.formatShort -import com.github.libretube.ui.adapters.CommentsAdapter +import com.github.libretube.ui.adapters.CommentPagingAdapter import com.github.libretube.ui.models.CommentsViewModel import com.github.libretube.ui.sheets.CommentsSheet +import kotlinx.coroutines.launch class CommentsMainFragment : Fragment() { private var _binding: FragmentCommentsBinding? = null - - private lateinit var commentsAdapter: CommentsAdapter - + private val binding get() = _binding!! private val viewModel: CommentsViewModel by activityViewModels() override fun onCreateView( @@ -28,13 +31,13 @@ class CommentsMainFragment : Fragment() { savedInstanceState: Bundle? ): View { _binding = FragmentCommentsBinding.inflate(inflater, container, false) - return _binding!!.root + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val binding = _binding ?: return + val binding = binding val layoutManager = LinearLayoutManager(requireContext()) binding.commentsRV.layoutManager = layoutManager binding.commentsRV.setItemViewCacheSize(20) @@ -47,68 +50,50 @@ class CommentsMainFragment : Fragment() { } binding.commentsRV.viewTreeObserver.addOnScrollChangedListener { - val viewBinding = _binding ?: return@addOnScrollChangedListener // save the last scroll position to become used next time when the sheet is opened viewModel.currentCommentsPosition = layoutManager.findFirstVisibleItemPosition() // hide or show the scroll to top button commentsSheet?.binding?.btnScrollToTop?.isVisible = viewModel.currentCommentsPosition != 0 - - if (!viewBinding.commentsRV.canScrollVertically(1)) { - viewModel.fetchNextComments() - } } commentsSheet?.updateFragmentInfo(false, getString(R.string.comments)) - commentsAdapter = CommentsAdapter( + val commentPagingAdapter = CommentPagingAdapter( this, - viewModel.videoId ?: return, + viewModel.videoIdLiveData.value ?: return, viewModel.channelAvatar ?: return, - viewModel.commentsPage.value?.comments.orEmpty().toMutableList(), - handleLink = viewModel.handleLink + handleLink = viewModel.handleLink, ) { viewModel.commentsSheetDismiss?.invoke() } - binding.commentsRV.adapter = commentsAdapter + binding.commentsRV.adapter = commentPagingAdapter - if (viewModel.commentsPage.value?.comments.isNullOrEmpty()) { - viewModel.fetchComments() - } else { - binding.commentsRV.scrollToPosition(viewModel.currentCommentsPosition) - } + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + commentPagingAdapter.loadStateFlow.collect { + binding.progress.isVisible = it.refresh is LoadState.Loading - viewModel.isLoading.observe(viewLifecycleOwner) { - _binding?.progress?.isVisible = it == true - } + if (it.append is LoadState.NotLoading && it.append.endOfPaginationReached) { + binding.errorTV.text = getString(R.string.no_comments_available) + binding.errorTV.isVisible = true + return@collect + } + } + } - // listen for new comments to be loaded - viewModel.commentsPage.observe(viewLifecycleOwner) { - if (it == null) return@observe - val viewBinding = _binding ?: return@observe + launch { + viewModel.commentsFlow.collect { + commentPagingAdapter.submitData(it) - if (it.disabled) { - viewBinding.errorTV.isVisible = true - return@observe + val commentCount = commentPagingAdapter.itemCount.toLong().formatShort() + commentsSheet?.updateFragmentInfo( + false, + getString(R.string.comments_count, commentCount) + ) + } + } } - - commentsSheet?.updateFragmentInfo( - false, - "${getString(R.string.comments)} (${it.commentCount.formatShort()})" - ) - if (it.comments.isEmpty()) { - viewBinding.errorTV.text = getString(R.string.no_comments_available) - viewBinding.errorTV.isVisible = true - return@observe - } - - // sometimes the received comments have the same size as the existing ones - // which causes comments.subList to throw InvalidArgumentException - if (commentsAdapter.itemCount > it.comments.size) return@observe - - commentsAdapter.updateItems( - // only add the new comments to the recycler view - it.comments.subList(commentsAdapter.itemCount, it.comments.size) - ) } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/CommentsRepliesFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/CommentsRepliesFragment.kt index 0ef31d890..69d67bdc8 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/CommentsRepliesFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/CommentsRepliesFragment.kt @@ -1,7 +1,6 @@ package com.github.libretube.ui.fragments import android.os.Bundle -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -10,41 +9,35 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.LoadState import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.R -import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.obj.Comment -import com.github.libretube.api.obj.CommentsPage import com.github.libretube.constants.IntentData import com.github.libretube.databinding.FragmentCommentsBinding -import com.github.libretube.extensions.TAG import com.github.libretube.extensions.formatShort import com.github.libretube.extensions.parcelable -import com.github.libretube.ui.adapters.CommentsAdapter -import com.github.libretube.ui.extensions.filterNonEmptyComments +import com.github.libretube.ui.adapters.CommentPagingAdapter import com.github.libretube.ui.models.CommentsViewModel import com.github.libretube.ui.sheets.CommentsSheet -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class CommentsRepliesFragment : Fragment() { private var _binding: FragmentCommentsBinding? = null + private val binding get() = _binding!! - private lateinit var repliesPage: CommentsPage - private lateinit var repliesAdapter: CommentsAdapter private val viewModel: CommentsViewModel by activityViewModels() - private var isLoading = false - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentCommentsBinding.inflate(inflater, container, false) - return _binding!!.root + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -53,18 +46,17 @@ class CommentsRepliesFragment : Fragment() { val videoId = arguments.getString(IntentData.videoId, "") val comment = arguments.parcelable(IntentData.comment)!! - val binding = _binding ?: return + val binding = binding val commentsSheet = parentFragment as? CommentsSheet commentsSheet?.binding?.btnScrollToTop?.isGone = true - repliesAdapter = CommentsAdapter( + val repliesAdapter = CommentPagingAdapter( null, videoId, viewModel.channelAvatar, - mutableListOf(comment), - true, - viewModel.handleLink + comment, + viewModel.handleLink, ) { viewModel.commentsSheetDismiss?.invoke() } @@ -77,49 +69,27 @@ class CommentsRepliesFragment : Fragment() { binding.commentsRV.layoutManager = LinearLayoutManager(context) binding.commentsRV.adapter = repliesAdapter - binding.commentsRV.viewTreeObserver.addOnScrollChangedListener { - if (_binding?.commentsRV?.canScrollVertically(1) == false && ::repliesPage.isInitialized) { - if (repliesPage.nextpage == null) return@addOnScrollChangedListener + viewModel.selectedCommentLiveData.postValue(comment.repliesPage) - fetchReplies(videoId, repliesPage.nextpage!!) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + repliesAdapter.loadStateFlow.collect { + binding.progress.isVisible = it.refresh is LoadState.Loading + } + } + + launch { + viewModel.commentRepliesFlow.collect { + repliesAdapter.submitData(it) + } + } } } - - loadInitialReplies(videoId, comment.repliesPage.orEmpty()) } override fun onDestroyView() { super.onDestroyView() _binding = null } - - private fun loadInitialReplies(videoId: String, nextPage: String) { - _binding?.progress?.isVisible = true - fetchReplies(videoId, nextPage) - } - - private fun fetchReplies(videoId: String, nextPage: String) { - _binding?.progress?.isVisible = true - - lifecycleScope.launch { - if (isLoading) return@launch - isLoading = true - - repliesPage = try { - withContext(Dispatchers.IO) { - RetrofitInstance.api.getCommentsNextPage(videoId, nextPage) - } - } catch (e: Exception) { - Log.e(TAG(), "IOException, you might not have internet connection") - return@launch - } finally { - _binding?.progress?.isGone = true - } - repliesPage.comments = repliesPage.comments.filterNonEmptyComments() - withContext(Dispatchers.Main) { - repliesAdapter.updateItems(repliesPage.comments) - } - isLoading = false - } - } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt index b29a2593b..723136f30 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt @@ -548,7 +548,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // set the max height to not cover the currently playing video commentsViewModel.handleLink = this::handleLink updateMaxSheetHeight() - commentsViewModel.videoId = videoId + commentsViewModel.videoIdLiveData.postValue(videoId) commentsViewModel.channelAvatar = streams.uploaderAvatar CommentsSheet().show(childFragmentManager) } diff --git a/app/src/main/java/com/github/libretube/ui/models/CommentsViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/CommentsViewModel.kt index c488dab0a..e1b8614fc 100644 --- a/app/src/main/java/com/github/libretube/ui/models/CommentsViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/CommentsViewModel.kt @@ -1,27 +1,45 @@ package com.github.libretube.ui.models -import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope -import com.github.libretube.api.RetrofitInstance -import com.github.libretube.api.obj.CommentsPage -import com.github.libretube.extensions.TAG -import com.github.libretube.ui.extensions.filterNonEmptyComments -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import com.github.libretube.ui.models.sources.CommentPagingSource +import com.github.libretube.ui.models.sources.CommentRepliesPagingSource +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest class CommentsViewModel : ViewModel() { - val commentsPage = MutableLiveData() + val videoIdLiveData = MutableLiveData() + val selectedCommentLiveData = MutableLiveData() + + @OptIn(ExperimentalCoroutinesApi::class) + val commentsFlow = videoIdLiveData.asFlow() + .flatMapLatest { + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + CommentPagingSource(it) + }.flow + } + .cachedIn(viewModelScope) + @OptIn(ExperimentalCoroutinesApi::class) + val commentRepliesFlow = videoIdLiveData.asFlow() + .combine(selectedCommentLiveData.asFlow()) { videoId, comment -> videoId to comment } + .flatMapLatest { (videoId, commentPage) -> + Pager(PagingConfig(20, enablePlaceholders = false)) { + CommentRepliesPagingSource(videoId, commentPage) + }.flow + } + .cachedIn(viewModelScope) + val commentSheetExpand = MutableLiveData() - var videoId: String? = null var channelAvatar: String? = null var handleLink: ((url: String) -> Unit)? = null - private var nextPage: String? = null - var isLoading = MutableLiveData() var currentCommentsPosition = 0 var commentsSheetDismiss: (() -> Unit)? = null @@ -31,62 +49,7 @@ class CommentsViewModel : ViewModel() { } } - fun fetchComments() { - val videoId = videoId ?: return - - isLoading.value = true - - viewModelScope.launch { - val response = try { - withContext(Dispatchers.IO) { - RetrofitInstance.api.getComments(videoId) - } - } catch (e: Exception) { - Log.e(TAG(), e.toString()) - return@launch - } finally { - isLoading.value = false - } - - nextPage = response.nextpage - response.comments = response.comments.filterNonEmptyComments() - commentsPage.postValue(response) - } - } - - fun fetchNextComments() { - if (isLoading.value == true || nextPage == null || videoId == null) return - - isLoading.value = true - - viewModelScope.launch { - val response = try { - withContext(Dispatchers.IO) { - RetrofitInstance.api.getCommentsNextPage(videoId!!, nextPage!!) - } - } catch (e: Exception) { - Log.e(TAG(), e.toString()) - return@launch - } finally { - isLoading.value = false - } - - val updatedPage = commentsPage.value?.apply { - comments += response.comments - .filterNonEmptyComments() - .filter { comment -> comments.none { it.commentId == comment.commentId } } - } - - nextPage = response.nextpage - commentsPage.postValue(updatedPage) - } - } - fun reset() { - isLoading.value = false - nextPage = null - commentsPage.value = null - videoId = null setCommentSheetExpand(null) currentCommentsPosition = 0 } diff --git a/app/src/main/java/com/github/libretube/ui/models/sources/CommentPagingSource.kt b/app/src/main/java/com/github/libretube/ui/models/sources/CommentPagingSource.kt new file mode 100644 index 000000000..d571fd4d7 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/models/sources/CommentPagingSource.kt @@ -0,0 +1,21 @@ +package com.github.libretube.ui.models.sources + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.obj.Comment + +class CommentPagingSource(private val videoId: String) : PagingSource() { + override fun getRefreshKey(state: PagingState) = null + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val result = params.key?.let { + RetrofitInstance.api.getCommentsNextPage(videoId, it) + } ?: RetrofitInstance.api.getComments(videoId) + LoadResult.Page(result.comments, null, result.nextpage) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} diff --git a/app/src/main/java/com/github/libretube/ui/models/sources/CommentRepliesPagingSource.kt b/app/src/main/java/com/github/libretube/ui/models/sources/CommentRepliesPagingSource.kt new file mode 100644 index 000000000..5884754a6 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/models/sources/CommentRepliesPagingSource.kt @@ -0,0 +1,23 @@ +package com.github.libretube.ui.models.sources + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.obj.Comment + +class CommentRepliesPagingSource( + private val videoId: String, + private val commentNextPage: String?, +) : PagingSource() { + override fun getRefreshKey(state: PagingState) = null + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val key = params.key.orEmpty().ifEmpty { commentNextPage.orEmpty() } + val result = RetrofitInstance.api.getCommentsNextPage(videoId, key) + LoadResult.Page(result.comments, null, result.nextpage) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98c673ed2..1183c6892 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,10 +66,12 @@ Donate %1$s • %2$d videos %1$s subscribers • %2$d videos + • %1$s %1$d videos Connect to the Internet first. Retry Comments + Comments (%1$s) Replies Channels All