diff --git a/app/src/main/java/com/github/libretube/ui/adapters/SearchChannelAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/SearchChannelAdapter.kt deleted file mode 100644 index 8a35f2b4b..000000000 --- a/app/src/main/java/com/github/libretube/ui/adapters/SearchChannelAdapter.kt +++ /dev/null @@ -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( - 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 - } - } - } -} diff --git a/app/src/main/java/com/github/libretube/ui/adapters/SearchResultsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/SearchResultsAdapter.kt index be1fc79cc..55ef3b965 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/SearchResultsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/SearchResultsAdapter.kt @@ -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( diff --git a/app/src/main/java/com/github/libretube/ui/fragments/ChannelContentFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/ChannelContentFragment.kt index ba546d0bf..1e005c9e1 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/ChannelContentFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/ChannelContentFragment.kt @@ -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(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(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 + } + } + } + } } } diff --git a/app/src/main/java/com/github/libretube/ui/models/sources/ChannelTabPagingSource.kt b/app/src/main/java/com/github/libretube/ui/models/sources/ChannelTabPagingSource.kt new file mode 100644 index 000000000..144d1dcef --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/models/sources/ChannelTabPagingSource.kt @@ -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() { + override fun getRefreshKey(state: PagingState) = null + + override suspend fun load(params: LoadParams): LoadResult { + 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) + } + } +} \ No newline at end of file