Merge pull request #6053 from Bnyro/channel-tabs

feat: changed chips in channels to swipable tabs
This commit is contained in:
Bnyro 2024-05-18 20:27:45 +02:00 committed by GitHub
commit 45fbef920a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 295 additions and 225 deletions

View File

@ -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"
}

View File

@ -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)
}

View File

@ -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<ChannelTab>(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<List<StreamItem>>(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
}
}

View File

@ -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<ChannelTab> = emptyList()
private var nextPages = Array<String?>(5) { null }
private var searchChannelAdapter: SearchChannelAdapter? = null
private lateinit var channelContentAdapter: ChannelContentAdapter
private var nextPages = Array<String?>(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<ChannelTab>) {
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<ChannelTab>,
private val videos: List<StreamItem>,
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
}
}

View File

@ -119,62 +119,24 @@
android:autoLink="web"
android:padding="10dp" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:scrollbars="none">
<com.google.android.material.chip.ChipGroup
android:id="@+id/tab_chips"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:checkedChip="@+id/videos"
app:selectionRequired="true"
app:singleLine="true"
app:singleSelection="true">
<com.google.android.material.chip.Chip
android:id="@id/videos"
style="@style/channelChip"
android:text="@string/videos"
android:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/shorts"
style="@style/channelChip"
android:text="@string/yt_shorts" />
<com.google.android.material.chip.Chip
android:id="@+id/livestreams"
style="@style/channelChip"
android:text="@string/livestreams" />
<com.google.android.material.chip.Chip
android:id="@+id/playlists"
style="@style/channelChip"
android:text="@string/playlists" />
<com.google.android.material.chip.Chip
android:id="@+id/albums"
style="@style/channelChip"
android:text="@string/albums" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
</LinearLayout>
</com.google.android.material.appbar.CollapsingToolbarLayout>
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable"
/>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/channel_recView"
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
app:layout_behavior="@string/appbar_scrolling_view_behavior"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</com.github.libretube.ui.views.CustomSwipeToRefresh>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.fragments.ChannelContentFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/channel_recView"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<ProgressBar
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -551,4 +551,5 @@
<string name="also_clear_watch_positions">Also clear watch positions</string>
<string name="import_temp_playlist">Import temporary playlist?</string>
<string name="import_temp_playlist_summary">Do you want to create a new playlist named \'%1$s\'? The playlist will contain %2$d videos.</string>
</resources>