refactor: use paging adapter for channel content items

This commit is contained in:
Bnyro 2025-03-03 14:22:56 +01:00
parent 2c73287d71
commit 4cde38504a
No known key found for this signature in database
4 changed files with 91 additions and 280 deletions

View File

@ -1,176 +0,0 @@
package com.github.libretube.ui.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.core.view.isGone
import androidx.recyclerview.widget.ListAdapter
import com.github.libretube.R
import com.github.libretube.api.JsonHelper
import com.github.libretube.api.obj.ContentItem
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.ChannelRowBinding
import com.github.libretube.databinding.PlaylistsRowBinding
import com.github.libretube.databinding.VideoRowBinding
import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.formatShort
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.ui.adapters.callbacks.DiffUtilItemCallback
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.extensions.setFormattedDuration
import com.github.libretube.ui.extensions.setWatchProgressLength
import com.github.libretube.ui.extensions.setupSubscriptionButton
import com.github.libretube.ui.sheets.ChannelOptionsBottomSheet
import com.github.libretube.ui.sheets.PlaylistOptionsBottomSheet
import com.github.libretube.ui.sheets.VideoOptionsBottomSheet
import com.github.libretube.ui.viewholders.SearchViewHolder
import com.github.libretube.util.TextUtils
import kotlinx.serialization.encodeToString
// TODO: Replace with SearchResultsAdapter when migrating the channel fragment to use Paging as well
class SearchChannelAdapter : ListAdapter<ContentItem, SearchViewHolder>(
DiffUtilItemCallback(
areItemsTheSame = { oldItem, newItem -> oldItem.url == newItem.url },
areContentsTheSame = { _, _ -> true },
)
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
return when (viewType) {
0 -> SearchViewHolder(VideoRowBinding.inflate(layoutInflater, parent, false))
1 -> SearchViewHolder(ChannelRowBinding.inflate(layoutInflater, parent, false))
2 -> SearchViewHolder(PlaylistsRowBinding.inflate(layoutInflater, parent, false))
else -> throw IllegalArgumentException("Invalid type")
}
}
override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
val searchItem = currentList[position]
val videoRowBinding = holder.videoRowBinding
val channelRowBinding = holder.channelRowBinding
val playlistRowBinding = holder.playlistRowBinding
if (videoRowBinding != null) {
bindVideo(searchItem, videoRowBinding, position)
} else if (channelRowBinding != null) {
bindChannel(searchItem, channelRowBinding)
} else if (playlistRowBinding != null) {
bindPlaylist(searchItem, playlistRowBinding)
}
}
override fun getItemViewType(position: Int): Int {
return when (currentList[position].type) {
StreamItem.TYPE_STREAM -> 0
StreamItem.TYPE_CHANNEL -> 1
StreamItem.TYPE_PLAYLIST -> 2
else -> 3
}
}
private fun bindVideo(item: ContentItem, binding: VideoRowBinding, position: Int) {
binding.apply {
ImageHelper.loadImage(item.thumbnail, thumbnail)
thumbnailDuration.setFormattedDuration(item.duration, item.isShort, item.uploaded)
videoTitle.text = item.title
videoInfo.text = TextUtils.formatViewsString(root.context, item.views, item.uploaded)
channelContainer.isGone = true
root.setOnClickListener {
NavigationHelper.navigateVideo(root.context, item.url)
}
val videoId = item.url.toID()
val activity = (root.context as BaseActivity)
val fragmentManager = activity.supportFragmentManager
root.setOnLongClickListener {
fragmentManager.setFragmentResultListener(
VideoOptionsBottomSheet.VIDEO_OPTIONS_SHEET_REQUEST_KEY,
activity
) { _, _ ->
notifyItemChanged(position)
}
val sheet = VideoOptionsBottomSheet()
val contentItemString = JsonHelper.json.encodeToString(item)
val streamItem: StreamItem = JsonHelper.json.decodeFromString(contentItemString)
sheet.arguments = bundleOf(IntentData.streamItem to streamItem)
sheet.show(fragmentManager, SearchChannelAdapter::class.java.name)
true
}
channelContainer.setOnClickListener {
NavigationHelper.navigateChannel(root.context, item.uploaderUrl)
}
watchProgress.setWatchProgressLength(videoId, item.duration)
}
}
private fun bindChannel(item: ContentItem, binding: ChannelRowBinding) {
binding.apply {
ImageHelper.loadImage(item.thumbnail, searchChannelImage, true)
searchChannelName.text = item.name
val subscribers = item.subscribers.formatShort()
searchViews.text = if (item.subscribers >= 0 && item.videos >= 0) {
root.context.getString(R.string.subscriberAndVideoCounts, subscribers, item.videos)
} else if (item.subscribers >= 0) {
root.context.getString(R.string.subscribers, subscribers)
} else if (item.videos >= 0) {
root.context.getString(R.string.videoCount, item.videos)
} else {
""
}
root.setOnClickListener {
NavigationHelper.navigateChannel(root.context, item.url)
}
var subscribed = false
binding.searchSubButton.setupSubscriptionButton(item.url.toID(), item.name?.toID()) {
subscribed = it
}
root.setOnLongClickListener {
val channelOptionsSheet = ChannelOptionsBottomSheet()
channelOptionsSheet.arguments = bundleOf(
IntentData.channelId to item.url.toID(),
IntentData.channelName to item.name,
IntentData.isSubscribed to subscribed
)
channelOptionsSheet.show((root.context as BaseActivity).supportFragmentManager)
true
}
}
}
private fun bindPlaylist(item: ContentItem, binding: PlaylistsRowBinding) {
binding.apply {
ImageHelper.loadImage(item.thumbnail, playlistThumbnail)
if (item.videos >= 0) videoCount.text = item.videos.toString()
playlistTitle.text = item.name
playlistDescription.text = item.uploaderName
root.setOnClickListener {
NavigationHelper.navigatePlaylist(root.context, item.url, PlaylistType.PUBLIC)
}
root.setOnLongClickListener {
val sheet = PlaylistOptionsBottomSheet()
sheet.arguments = bundleOf(
IntentData.playlistId to item.url.toID(),
IntentData.playlistName to item.name.orEmpty(),
IntentData.playlistType to PlaylistType.PUBLIC
)
sheet.show(
(root.context as BaseActivity).supportFragmentManager,
PlaylistOptionsBottomSheet::class.java.name
)
true
}
}
}
}

View File

@ -3,6 +3,7 @@ package com.github.libretube.ui.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.paging.PagingDataAdapter
import com.github.libretube.R
@ -91,10 +92,12 @@ class SearchResultsAdapter(
private fun bindVideo(item: ContentItem, binding: VideoRowBinding, position: Int) {
binding.apply {
ImageHelper.loadImage(item.thumbnail, thumbnail)
thumbnailDuration.setFormattedDuration(item.duration, item.isShort, item.uploaded)
videoTitle.text = item.title
videoInfo.text = TextUtils.formatViewsString(root.context, item.views, item.uploaded)
channelContainer.isGone = item.uploaderAvatar.isNullOrEmpty()
channelName.text = item.uploaderName
ImageHelper.loadImage(item.uploaderAvatar, channelImage, true)
@ -184,12 +187,10 @@ class SearchResultsAdapter(
}
root.setOnLongClickListener {
val playlistId = item.url.toID()
val playlistName = item.name!!
val sheet = PlaylistOptionsBottomSheet()
sheet.arguments = bundleOf(
IntentData.playlistId to playlistId,
IntentData.playlistName to playlistName,
IntentData.playlistId to item.url.toID(),
IntentData.playlistName to item.name.orEmpty(),
IntentData.playlistType to PlaylistType.PUBLIC
)
sheet.show(

View File

@ -3,26 +3,27 @@ package com.github.libretube.ui.fragments
import android.content.res.Configuration
import android.os.Bundle
import android.os.Parcelable
import android.util.Log
import android.view.View
import androidx.core.view.isGone
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.obj.ChannelTab
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.FragmentChannelContentBinding
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.ceilHalf
import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.parcelableArrayList
import com.github.libretube.ui.adapters.SearchChannelAdapter
import com.github.libretube.ui.adapters.SearchResultsAdapter
import com.github.libretube.ui.adapters.VideosAdapter
import com.github.libretube.ui.base.DynamicLayoutManagerFragment
import com.github.libretube.ui.extensions.addOnBottomReachedListener
import com.github.libretube.ui.models.sources.ChannelTabPagingSource
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -31,12 +32,7 @@ import kotlinx.coroutines.withContext
class ChannelContentFragment : DynamicLayoutManagerFragment(R.layout.fragment_channel_content) {
private var _binding: FragmentChannelContentBinding? = null
private val binding get() = _binding!!
private var channelId: String? = null
private var searchChannelAdapter: SearchChannelAdapter? = null
private var channelAdapter: VideosAdapter? = null
private var recyclerViewState: Parcelable? = null
private var nextPage: String? = null
private var isLoading: Boolean = false
override fun setLayoutManagers(gridItems: Int) {
binding.channelRecView.layoutManager = GridLayoutManager(
@ -45,109 +41,26 @@ class ChannelContentFragment : DynamicLayoutManagerFragment(R.layout.fragment_ch
)
}
private suspend fun fetchChannelNextPage(nextPage: String): String? {
val response = withContext(Dispatchers.IO) {
MediaServiceRepository.instance.getChannelNextPage(channelId!!, nextPage).apply {
relatedStreams = relatedStreams.deArrow()
}
}
channelAdapter?.insertItems(response.relatedStreams)
return response.nextpage
}
private suspend fun fetchTabNextPage(nextPage: String, tab: ChannelTab): String? {
val newContent = withContext(Dispatchers.IO) {
MediaServiceRepository.instance.getChannelTab(tab.data, nextPage)
}.apply {
content = content.deArrow()
}
searchChannelAdapter?.let {
it.submitList(it.currentList + newContent.content)
}
return newContent.nextpage
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// manually restore the recyclerview state due to https://github.com/material-components/material-components-android/issues/3473
binding.channelRecView.layoutManager?.onRestoreInstanceState(recyclerViewState)
}
private fun loadChannelTab(tab: ChannelTab) = lifecycleScope.launch {
val response = try {
withContext(Dispatchers.IO) {
MediaServiceRepository.instance.getChannelTab(tab.data)
}.apply {
content = content.deArrow()
}
} catch (e: Exception) {
_binding?.progressBar?.isGone = true
return@launch
}
nextPage = response.nextpage
searchChannelAdapter?.submitList(response.content)
val binding = _binding ?: return@launch
binding.progressBar.isGone = true
isLoading = false
}
private fun loadNextPage(isVideo: Boolean, tab: ChannelTab) {
if (isLoading) return
lifecycleScope.launch {
try {
isLoading = true
nextPage = if (isVideo) {
fetchChannelNextPage(nextPage ?: return@launch)
} else {
fetchTabNextPage(nextPage ?: return@launch, tab)
}
} catch (e: Exception) {
Log.e(TAG(), e.toString())
}
isLoading = false
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
_binding = FragmentChannelContentBinding.bind(view)
super.onViewCreated(view, savedInstanceState)
val arguments = requireArguments()
val channelId = arguments.getString(IntentData.channelId)!!
val tabData = arguments.parcelable<ChannelTab>(IntentData.tabData)
channelId = arguments.getString(IntentData.channelId)
nextPage = arguments.getString(IntentData.nextPage)
searchChannelAdapter = SearchChannelAdapter()
binding.channelRecView.adapter = searchChannelAdapter
binding.channelRecView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
recyclerViewState = binding.channelRecView.layoutManager?.onSaveInstanceState()
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (_binding == null || isLoading) return
val visibleItemCount = recyclerView.layoutManager!!.childCount
val totalItemCount = recyclerView.layoutManager!!.getItemCount()
val firstVisibleItemPosition =
(recyclerView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
if (firstVisibleItemPosition + visibleItemCount >= totalItemCount) {
loadNextPage(tabData?.data!!.isEmpty(), tabData)
}
}
})
if (tabData?.data.isNullOrEmpty()) {
channelAdapter = VideosAdapter(
var nextPage = arguments.getString(IntentData.nextPage)
var isLoading = false
val channelAdapter = VideosAdapter(
forceMode = VideosAdapter.Companion.LayoutMode.CHANNEL_ROW
).also {
it.submitList(arguments.parcelableArrayList<StreamItem>(IntentData.videoList)!!)
@ -155,8 +68,52 @@ class ChannelContentFragment : DynamicLayoutManagerFragment(R.layout.fragment_ch
binding.channelRecView.adapter = channelAdapter
binding.progressBar.isGone = true
binding.channelRecView.addOnBottomReachedListener {
if (isLoading || nextPage == null) return@addOnBottomReachedListener
isLoading = true
lifecycleScope.launch(Dispatchers.IO) {
val resp = try {
MediaServiceRepository.instance.getChannelNextPage(channelId, nextPage!!).apply {
relatedStreams = relatedStreams.deArrow()
}
} catch (e: Exception) {
return@launch
} finally {
isLoading = false
}
nextPage = resp.nextpage
withContext(Dispatchers.Main) {
channelAdapter.insertItems(resp.relatedStreams)
}
}
}
} else {
loadChannelTab(tabData ?: return)
val searchChannelAdapter = SearchResultsAdapter()
binding.channelRecView.adapter = searchChannelAdapter
val pagingFlow = Pager(
PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { ChannelTabPagingSource(tabData!!) }
).flow
viewLifecycleOwner.lifecycleScope.launch {
launch {
pagingFlow.collect {
searchChannelAdapter.submitData(it)
}
}
launch {
searchChannelAdapter.loadStateFlow.collect {
if (it.refresh is LoadState.NotLoading) {
binding.progressBar.isGone = true
}
}
}
}
}
}

View File

@ -0,0 +1,29 @@
package com.github.libretube.ui.models.sources
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.obj.ChannelTab
import com.github.libretube.api.obj.ContentItem
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ChannelTabPagingSource(
private val tab: ChannelTab
): PagingSource<String, ContentItem>() {
override fun getRefreshKey(state: PagingState<String, ContentItem>) = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, ContentItem> {
return try {
val resp = withContext(Dispatchers.IO) {
MediaServiceRepository.instance.getChannelTab(tab.data, params.key).apply {
content = content.deArrow()
}
}
LoadResult.Page(resp.content, null, resp.nextpage)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}