From f41e3b24bb114d9f50914d072f902aa39d6a4466 Mon Sep 17 00:00:00 2001 From: Amandeep singh Date: Mon, 13 May 2024 18:35:03 +0530 Subject: [PATCH] feat: changed chips in channels to swipable tabs --- .../github/libretube/constants/IntentData.kt | 3 + .../com/github/libretube/obj/ChannelTabs.kt | 14 - .../ui/fragments/ChannelContentFragment.kt | 174 +++++++++++++ .../libretube/ui/fragments/ChannelFragment.kt | 242 ++++++------------ app/src/main/res/layout/fragment_channel.xml | 58 +---- .../res/layout/fragment_channel_content.xml | 28 ++ app/src/main/res/values/strings.xml | 1 + 7 files changed, 295 insertions(+), 225 deletions(-) delete mode 100644 app/src/main/java/com/github/libretube/obj/ChannelTabs.kt create mode 100644 app/src/main/java/com/github/libretube/ui/fragments/ChannelContentFragment.kt create mode 100644 app/src/main/res/layout/fragment_channel_content.xml diff --git a/app/src/main/java/com/github/libretube/constants/IntentData.kt b/app/src/main/java/com/github/libretube/constants/IntentData.kt index 91df36f6d..b64574da2 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -43,4 +43,7 @@ object IntentData { const val audioLanguage = "audioLanguage" const val captionLanguage = "captionLanguage" const val wasIntentStopped = "wasIntentStopped" + const val tabData = "tabData" + const val videoList = "videoList" + const val nextPage = "nextPage" } diff --git a/app/src/main/java/com/github/libretube/obj/ChannelTabs.kt b/app/src/main/java/com/github/libretube/obj/ChannelTabs.kt deleted file mode 100644 index 6e6787715..000000000 --- a/app/src/main/java/com/github/libretube/obj/ChannelTabs.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.libretube.obj - -import androidx.annotation.IdRes -import com.github.libretube.R - -sealed class ChannelTabs( - val identifierName: String, - @IdRes val chipId: Int -) { - object Playlists : ChannelTabs("playlists", R.id.playlists) - object Shorts : ChannelTabs("shorts", R.id.shorts) - object Livestreams : ChannelTabs("livestreams", R.id.livestreams) - object Albums : ChannelTabs("albums", R.id.albums) -} 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 new file mode 100644 index 000000000..3843fbf61 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/fragments/ChannelContentFragment.kt @@ -0,0 +1,174 @@ +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.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.api.RetrofitInstance +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.ui.adapters.SearchChannelAdapter +import com.github.libretube.ui.adapters.VideosAdapter +import com.github.libretube.ui.base.DynamicLayoutManagerFragment +import com.github.libretube.util.deArrow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +class ChannelContentFragment : DynamicLayoutManagerFragment() { + 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 onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentChannelContentBinding.inflate(inflater, container, false) + return _binding!!.root + } + + override fun setLayoutManagers(gridItems: Int) { + binding.channelRecView.layoutManager = GridLayoutManager( + requireContext(), + gridItems.ceilHalf() + ) + } + + private suspend fun fetchChannelNextPage(nextPage: String): String? { + val response = withContext(Dispatchers.IO) { + RetrofitInstance.api.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) { + RetrofitInstance.api.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) { + RetrofitInstance.api.getChannelTab(tab.data) + }.apply { + content = content.deArrow() + } + } catch (e: Exception) { + binding.progressBar.isGone = true + return@launch + } + nextPage = response.nextpage + + val binding = _binding ?: return@launch + searchChannelAdapter = SearchChannelAdapter() + binding.channelRecView.adapter = searchChannelAdapter + searchChannelAdapter?.submitList(response.content) + binding.progressBar.isGone = true + + isLoading = false + } + + private fun loadNextPage(isVideo: Boolean, tab: ChannelTab) = 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?) { + super.onViewCreated(view, savedInstanceState) + val tabData = kotlin.runCatching { + Json.decodeFromString(arguments?.getString(IntentData.tabData) ?: "") + }.getOrNull() + + channelId = arguments?.getString(IntentData.channelId) + nextPage = arguments?.getString(IntentData.nextPage) + + 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) + val visibleItemCount = recyclerView.layoutManager!!.childCount + val totalItemCount = recyclerView.layoutManager!!.getItemCount() + val firstVisibleItemPosition = + (recyclerView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + + if (_binding == null || isLoading) return + if (firstVisibleItemPosition + visibleItemCount >= totalItemCount) { + loadNextPage(tabData?.data!!.isEmpty(), tabData) + isLoading = false + } + + } + }) + + if (tabData?.data.isNullOrEmpty()) { + val videoDataString = arguments?.getString(IntentData.videoList) + val videos = runCatching { + Json.decodeFromString>(videoDataString!!) + }.getOrElse { mutableListOf() } + channelAdapter = VideosAdapter( + videos.toMutableList(), + forceMode = VideosAdapter.Companion.LayoutMode.CHANNEL_ROW + ) + binding.channelRecView.adapter = channelAdapter + binding.progressBar.isGone = true + + } else { + loadChannelTab(tabData ?: return) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/fragments/ChannelFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/ChannelFragment.kt index b32a9bf68..e05ca22fb 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/ChannelFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/ChannelFragment.kt @@ -1,45 +1,44 @@ 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.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf -import androidx.core.view.children import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 import com.github.libretube.R import com.github.libretube.api.RetrofitInstance 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.FragmentChannelBinding import com.github.libretube.enums.ShareObjectType import com.github.libretube.extensions.TAG -import com.github.libretube.extensions.ceilHalf 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.obj.ChannelTabs import com.github.libretube.obj.ShareData -import com.github.libretube.ui.adapters.SearchChannelAdapter import com.github.libretube.ui.adapters.VideosAdapter import com.github.libretube.ui.base.DynamicLayoutManagerFragment import com.github.libretube.ui.dialogs.ShareDialog -import com.github.libretube.ui.extensions.addOnBottomReachedListener import com.github.libretube.ui.extensions.setupSubscriptionButton import com.github.libretube.ui.sheets.AddChannelToGroupSheet import com.github.libretube.util.deArrow +import com.google.android.material.tabs.TabLayoutMediator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import retrofit2.HttpException import java.io.IOException @@ -53,18 +52,21 @@ class ChannelFragment : DynamicLayoutManagerFragment() { private var channelAdapter: VideosAdapter? = null private var isLoading = true - private val possibleTabs = arrayOf( - ChannelTabs.Shorts, - ChannelTabs.Livestreams, - ChannelTabs.Playlists, - ChannelTabs.Albums - ) - private var channelTabs: List = emptyList() - private var nextPages = Array(5) { null } - private var searchChannelAdapter: SearchChannelAdapter? = null + private lateinit var channelContentAdapter: ChannelContentAdapter + private var nextPages = Array(5) { null } private var isAppBarFullyExpanded: Boolean = true - private var recyclerViewState: Parcelable? = null + private val tabList = mutableListOf( + ChannelTab(VIDEOS_TAB_KEY, "") + ) + + private val tabNamesMap = mapOf( + VIDEOS_TAB_KEY to R.string.videos, + "shorts" to R.string.yt_shorts, + "livestreams" to R.string.livestreams, + "playlists" to R.string.playlists, + "albums" to R.string.albums + ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -83,21 +85,17 @@ class ChannelFragment : DynamicLayoutManagerFragment() { return binding.root } - override fun setLayoutManagers(gridItems: Int) { - _binding?.channelRecView?.layoutManager = GridLayoutManager( - context, - gridItems.ceilHalf() - ) - } + override fun setLayoutManagers(gridItems: Int) {} override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // Check if the AppBarLayout is fully expanded binding.channelAppBar.addOnOffsetChangedListener { _, verticalOffset -> isAppBarFullyExpanded = verticalOffset == 0 } + binding.pager.reduceDragSensitivity() + // Determine if the child can scroll up binding.channelRefresh.setOnChildScrollUpCallback { _, _ -> !isAppBarFullyExpanded @@ -107,58 +105,26 @@ class ChannelFragment : DynamicLayoutManagerFragment() { fetchChannel() } - binding.channelRecView.addOnBottomReachedListener { - if (_binding == null || isLoading) return@addOnBottomReachedListener - - loadNextPage() - } - - binding.channelRecView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - recyclerViewState = binding.channelRecView.layoutManager?.onSaveInstanceState() - } - }) - fetchChannel() } + // adjust sensitivity due to the issue of viewpager2 with SwipeToRefresh https://issuetracker.google.com/issues/138314213 + private fun ViewPager2.reduceDragSensitivity() { + val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView") + recyclerViewField.isAccessible = true + val recyclerView = recyclerViewField.get(this) as RecyclerView + + val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop") + touchSlopField.isAccessible = true + val touchSlop = touchSlopField.get(recyclerView) as Int + touchSlopField.set(recyclerView, touchSlop * 3) + } + override fun onDestroyView() { super.onDestroyView() _binding = null } - private fun loadNextPage() = lifecycleScope.launch { - val binding = _binding ?: return@launch - - binding.channelRefresh.isRefreshing = true - isLoading = true - - try { - if (binding.tabChips.checkedChipId == binding.videos.id) { - fetchChannelNextPage(nextPages[0] ?: return@launch).let { - nextPages[0] = it - } - } else { - val currentTabIndex = binding.tabChips.children.indexOfFirst { - it.id == binding.tabChips.checkedChipId - } - val channelTab = channelTabs.first { tab -> - tab.name == possibleTabs[currentTabIndex - 1].identifierName - } - val nextPage = nextPages[currentTabIndex] ?: return@launch - fetchTabNextPage(nextPage, channelTab).let { - nextPages[currentTabIndex] = it - } - } - } catch (e: Exception) { - Log.e("error fetching tabs", e.toString()) - } - }.invokeOnCompletion { - _binding?.channelRefresh?.isRefreshing = false - isLoading = false - } - private fun fetchChannel() = lifecycleScope.launch { isLoading = true binding.channelRefresh.isRefreshing = true @@ -255,105 +221,55 @@ class ChannelFragment : DynamicLayoutManagerFragment() { ) } - // recyclerview of the videos by the channel + channelContentAdapter = ChannelContentAdapter( + tabList, + response.relatedStreams, + response.nextpage, + channelId, + this@ChannelFragment + ) + binding.pager.adapter = channelContentAdapter + TabLayoutMediator(binding.tabParent, binding.pager) { tab, position -> + tab.text = tabList[position].name + }.attach() + channelAdapter = VideosAdapter( response.relatedStreams.toMutableList(), forceMode = VideosAdapter.Companion.LayoutMode.CHANNEL_ROW ) - binding.channelRecView.adapter = channelAdapter - - setupTabs(response.tabs) + tabList.removeAll { tab -> + tab.name != VIDEOS_TAB_KEY + } + response.tabs.forEach { channelTab -> + val tabName = tabNamesMap[channelTab.name]?.let { getString(it) } + ?: channelTab.name.replaceFirstChar(Char::titlecase) + tabList.add(ChannelTab(tabName, channelTab.data)) + } + channelContentAdapter.notifyItemRangeChanged(0, tabList.size - 1) } - private fun setupTabs(tabs: List) { - this.channelTabs = tabs - - val binding = _binding ?: return - - binding.tabChips.children.forEach { chip -> - val resourceTab = possibleTabs.firstOrNull { it.chipId == chip.id } - resourceTab?.let { resTab -> - if (tabs.any { it.name == resTab.identifierName }) chip.isVisible = true - } - } - - binding.tabChips.setOnCheckedStateChangeListener { _, _ -> - when (binding.tabChips.checkedChipId) { - binding.videos.id -> { - binding.channelRecView.adapter = channelAdapter - } - - else -> { - possibleTabs.first { binding.tabChips.checkedChipId == it.chipId }.let { - val tab = tabs.first { tab -> tab.name == it.identifierName } - loadChannelTab(tab) - } - } - } - } - - // Load selected chip content if it's not videos tab. - possibleTabs.firstOrNull { binding.tabChips.checkedChipId == it.chipId }?.let { - val tab = tabs.first { tab -> tab.name == it.identifierName } - loadChannelTab(tab) - } - } - - private fun loadChannelTab(tab: ChannelTab) = lifecycleScope.launch { - binding.channelRefresh.isRefreshing = true - isLoading = true - - val response = try { - withContext(Dispatchers.IO) { - RetrofitInstance.api.getChannelTab(tab.data) - }.apply { - content = content.deArrow() - } - } catch (e: Exception) { - return@launch - } - nextPages[channelTabs.indexOf(tab) + 1] = response.nextpage - - val binding = _binding ?: return@launch - - searchChannelAdapter = SearchChannelAdapter() - binding.channelRecView.adapter = searchChannelAdapter - searchChannelAdapter?.submitList(response.content) - - binding.channelRefresh.isRefreshing = false - isLoading = false - } - - private suspend fun fetchChannelNextPage(nextPage: String): String? { - val response = withContext(Dispatchers.IO) { - RetrofitInstance.api.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) { - RetrofitInstance.api.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) + companion object { + private const val VIDEOS_TAB_KEY = "videos" + } +} + +class ChannelContentAdapter( + private val list: List, + private val videos: List, + private val nextPage: String?, + private val channelId: String?, + fragment: Fragment +) : FragmentStateAdapter(fragment) { + override fun getItemCount() = list.size + + override fun createFragment(position: Int): Fragment { + val fragment = ChannelContentFragment() + fragment.arguments = bundleOf( + IntentData.tabData to Json.encodeToString(list[position]), + IntentData.videoList to Json.encodeToString(videos), + IntentData.channelId to channelId, + IntentData.nextPage to nextPage + ) + return fragment } } diff --git a/app/src/main/res/layout/fragment_channel.xml b/app/src/main/res/layout/fragment_channel.xml index 1b1aab0e7..05e748c04 100644 --- a/app/src/main/res/layout/fragment_channel.xml +++ b/app/src/main/res/layout/fragment_channel.xml @@ -119,62 +119,24 @@ android:autoLink="web" android:padding="10dp" /> - - - - - - - - - - - - - - - - - - + - + app:layout_behavior="@string/appbar_scrolling_view_behavior" + /> diff --git a/app/src/main/res/layout/fragment_channel_content.xml b/app/src/main/res/layout/fragment_channel_content.xml new file mode 100644 index 000000000..2213a1583 --- /dev/null +++ b/app/src/main/res/layout/fragment_channel_content.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d1bc7541..58d879af8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -551,4 +551,5 @@ Also clear watch positions Import temporary playlist? Do you want to create a new playlist named \'%1$s\'? The playlist will contain %2$d videos. + \ No newline at end of file