refactor: Rewrite search functionality using Paging (#5528)

* refactor: Rewrite search functionality using Paging

* Apply code review comments

* Update app/src/main/java/com/github/libretube/ui/adapters/SearchChannelAdapter.kt

Co-authored-by: Bnyro <82752168+Bnyro@users.noreply.github.com>

* Apply code review suggestions

---------

Co-authored-by: Bnyro <82752168+Bnyro@users.noreply.github.com>
This commit is contained in:
Isira Seneviratne 2024-01-25 15:30:40 +05:30 committed by GitHub
parent a9cfd48ffd
commit 858dbe7ede
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 305 additions and 114 deletions

View File

@ -141,4 +141,7 @@ dependencies {
/* Baseline profile generation */
implementation(libs.androidx.profileinstaller)
"baselineProfile"(project(":baselineprofile"))
/* AndroidX Paging */
implementation(libs.androidx.paging)
}

View File

@ -0,0 +1,185 @@
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.SearchCallback
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>(SearchCallback) {
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 {
thumbnail.setImageDrawable(null)
ImageHelper.loadImage(item.thumbnail, thumbnail)
thumbnailDuration.setFormattedDuration(item.duration, item.isShort)
videoTitle.text = item.title
val viewsString = item.views.takeIf { it != -1L }?.formatShort().orEmpty()
val uploadDate = item.uploaded.takeIf { it > 0 }?.let {
" ${TextUtils.SEPARATOR} ${TextUtils.formatRelativeDate(root.context, it)}"
}.orEmpty()
videoInfo.text = root.context.getString(
R.string.normal_views,
viewsString,
uploadDate
)
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 {
searchChannelImage.setImageDrawable(null)
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 {
playlistThumbnail.setImageDrawable(null)
ImageHelper.loadImage(item.thumbnail, playlistThumbnail)
if (item.videos != -1L) 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 playlistId = item.url.toID()
val playlistName = item.name!!
val sheet = PlaylistOptionsBottomSheet()
sheet.arguments = bundleOf(
IntentData.playlistId to playlistId,
IntentData.playlistName to playlistName,
IntentData.playlistType to PlaylistType.PUBLIC
)
sheet.show(
(root.context as BaseActivity).supportFragmentManager,
PlaylistOptionsBottomSheet::class.java.name
)
true
}
}
}
}

View File

@ -3,9 +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.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.paging.PagingDataAdapter
import com.github.libretube.R
import com.github.libretube.api.JsonHelper
import com.github.libretube.api.obj.ContentItem
@ -19,6 +17,7 @@ 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.SearchCallback
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.extensions.setFormattedDuration
import com.github.libretube.ui.extensions.setWatchProgressLength
@ -30,10 +29,9 @@ import com.github.libretube.ui.viewholders.SearchViewHolder
import com.github.libretube.util.TextUtils
import kotlinx.serialization.encodeToString
class SearchAdapter(
private val isChannelAdapter: Boolean = false,
class SearchResultsAdapter(
private val timeStamp: Long = 0
) : ListAdapter<ContentItem, SearchViewHolder>(SearchCallback) {
) : PagingDataAdapter<ContentItem, SearchViewHolder>(SearchCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
@ -55,7 +53,7 @@ class SearchAdapter(
}
override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
val searchItem = currentList[position]
val searchItem = getItem(position)!!
val videoRowBinding = holder.videoRowBinding
val channelRowBinding = holder.channelRowBinding
@ -71,7 +69,7 @@ class SearchAdapter(
}
override fun getItemViewType(position: Int): Int {
return when (currentList[position].type) {
return when (getItem(position)?.type) {
StreamItem.TYPE_STREAM -> 0
StreamItem.TYPE_CHANNEL -> 1
StreamItem.TYPE_PLAYLIST -> 2
@ -95,13 +93,8 @@ class SearchAdapter(
uploadDate
)
// only display channel related info if not in a channel tab
if (!isChannelAdapter) {
channelName.text = item.uploaderName
ImageHelper.loadImage(item.uploaderAvatar, channelImage, true)
} else {
channelContainer.isGone = true
}
channelName.text = item.uploaderName
ImageHelper.loadImage(item.uploaderAvatar, channelImage, true)
root.setOnClickListener {
NavigationHelper.navigateVideo(root.context, item.url, timestamp = timeStamp)
@ -121,7 +114,7 @@ class SearchAdapter(
val contentItemString = JsonHelper.json.encodeToString(item)
val streamItem: StreamItem = JsonHelper.json.decodeFromString(contentItemString)
sheet.arguments = bundleOf(IntentData.streamItem to streamItem)
sheet.show(fragmentManager, SearchAdapter::class.java.name)
sheet.show(fragmentManager, SearchResultsAdapter::class.java.name)
true
}
channelContainer.setOnClickListener {
@ -196,14 +189,4 @@ class SearchAdapter(
}
}
}
private object SearchCallback : DiffUtil.ItemCallback<ContentItem>() {
override fun areItemsTheSame(oldItem: ContentItem, newItem: ContentItem): Boolean {
return oldItem.url == newItem.url
}
override fun areContentsTheSame(oldItem: ContentItem, newItem: ContentItem): Boolean {
return true
}
}
}

View File

@ -0,0 +1,12 @@
package com.github.libretube.ui.adapters.callbacks
import androidx.recyclerview.widget.DiffUtil
import com.github.libretube.api.obj.ContentItem
object SearchCallback : DiffUtil.ItemCallback<ContentItem>() {
override fun areItemsTheSame(oldItem: ContentItem, newItem: ContentItem): Boolean {
return oldItem.url == newItem.url
}
override fun areContentsTheSame(oldItem: ContentItem, newItem: ContentItem) = true
}

View File

@ -26,7 +26,7 @@ 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.SearchAdapter
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
@ -57,7 +57,7 @@ class ChannelFragment : DynamicLayoutManagerFragment() {
)
private var channelTabs: List<ChannelTab> = emptyList()
private var nextPages = Array<String?>(5) { null }
private var searchAdapter: SearchAdapter? = null
private var searchChannelAdapter: SearchChannelAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -293,9 +293,9 @@ class ChannelFragment : DynamicLayoutManagerFragment() {
val binding = _binding ?: return@launch
searchAdapter = SearchAdapter(true)
binding.channelRecView.adapter = searchAdapter
searchAdapter?.submitList(response.content)
searchChannelAdapter = SearchChannelAdapter()
binding.channelRecView.adapter = searchChannelAdapter
searchChannelAdapter?.submitList(response.content)
binding.channelRefresh.isRefreshing = false
isLoading = false
@ -320,7 +320,7 @@ class ChannelFragment : DynamicLayoutManagerFragment() {
content = content.deArrow()
}
searchAdapter?.let {
searchChannelAdapter?.let {
it.submitList(it.currentList + newContent.content)
}

View File

@ -1,47 +1,40 @@
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
import androidx.core.view.SoftwareKeyboardControllerCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.navArgs
import androidx.paging.LoadState
import androidx.recyclerview.widget.GridLayoutManager
import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.FragmentSearchResultBinding
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.db.obj.SearchHistoryItem
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.ceilHalf
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.adapters.SearchAdapter
import com.github.libretube.ui.adapters.SearchResultsAdapter
import com.github.libretube.ui.base.DynamicLayoutManagerFragment
import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.util.TextUtils
import com.github.libretube.ui.models.SearchResultViewModel
import com.github.libretube.util.TextUtils.toTimeInSeconds
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
class SearchResultFragment : DynamicLayoutManagerFragment() {
private var _binding: FragmentSearchResultBinding? = null
private val binding get() = _binding!!
private val args by navArgs<SearchResultFragmentArgs>()
private var nextPage: String? = null
private lateinit var searchAdapter: SearchAdapter
private var searchFilter = "all"
private val viewModel by viewModels<SearchResultViewModel>()
override fun onCreateView(
inflater: LayoutInflater,
@ -68,7 +61,7 @@ class SearchResultFragment : DynamicLayoutManagerFragment() {
// filter options
binding.filterChipGroup.setOnCheckedStateChangeListener { _, _ ->
searchFilter = when (
viewModel.setFilter(when (
binding.filterChipGroup.checkedChipId
) {
R.id.chip_all -> "all"
@ -81,80 +74,28 @@ class SearchResultFragment : DynamicLayoutManagerFragment() {
R.id.chip_music_playlists -> "music_playlists"
R.id.chip_music_artists -> "music_artists"
else -> throw IllegalArgumentException("Filter out of range")
}
fetchSearch()
})
}
fetchSearch()
val timeStamp = args.query.toHttpUrlOrNull()?.queryParameter("t")?.toTimeInSeconds()
val searchResultsAdapter = SearchResultsAdapter(timeStamp ?: 0)
binding.searchRecycler.adapter = searchResultsAdapter
binding.searchRecycler.viewTreeObserver.addOnScrollChangedListener {
if (_binding?.searchRecycler?.canScrollVertically(1) == false &&
nextPage != null
) {
fetchNextSearchItems()
}
}
}
private fun fetchSearch() {
_binding?.progress?.isVisible = true
_binding?.searchResultsLayout?.isGone = true
lifecycleScope.launch {
var timeStamp: Long? = null
// parse search URLs from YouTube entered in the search bar
val searchQuery = args.query.toHttpUrlOrNull()?.let {
val videoId = TextUtils.getVideoIdFromUrl(it.toString()) ?: args.query
timeStamp = it.queryParameter("t")?.toTimeInSeconds()
"${ShareDialog.YOUTUBE_FRONTEND_URL}/watch?v=$videoId"
} ?: args.query
view?.let { SoftwareKeyboardControllerCompat(it).hide() }
val response = try {
withContext(Dispatchers.IO) {
RetrofitInstance.api.getSearchResults(searchQuery, searchFilter).apply {
items = items.deArrow()
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
searchResultsAdapter.loadStateFlow.collect {
val isLoading = it.source.refresh is LoadState.Loading
binding.progress.isVisible = isLoading
binding.searchResultsLayout.isGone = isLoading
}
} catch (e: Exception) {
Log.e(TAG(), e.toString())
context?.toastFromMainDispatcher(R.string.unknown_error)
return@launch
}
val binding = _binding ?: return@launch
searchAdapter = SearchAdapter(timeStamp = timeStamp ?: 0)
binding.searchRecycler.adapter = searchAdapter
searchAdapter.submitList(response.items)
binding.searchResultsLayout.isVisible = true
binding.progress.isGone = true
binding.noSearchResult.isVisible = response.items.isEmpty()
nextPage = response.nextpage
}
}
private fun fetchNextSearchItems() {
lifecycleScope.launch {
val response = try {
withContext(Dispatchers.IO) {
RetrofitInstance.api.getSearchResultsNextPage(
args.query,
searchFilter,
nextPage!!
).apply {
items = items.deArrow()
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.searchResultsFlow.collectLatest {
searchResultsAdapter.submitData(it)
}
} catch (e: Exception) {
Log.e(TAG(), e.toString())
return@launch
}
nextPage = response.nextpage
if (response.items.isNotEmpty()) {
searchAdapter.submitList(searchAdapter.currentList + response.items)
}
}
}

View File

@ -0,0 +1,39 @@
package com.github.libretube.ui.models
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.fragments.SearchResultFragmentArgs
import com.github.libretube.ui.models.sources.SearchPagingSource
import com.github.libretube.util.TextUtils
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
class SearchResultViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
private val args = SearchResultFragmentArgs.fromSavedStateHandle(savedStateHandle)
// parse search URLs from YouTube entered in the search bar
private val searchQuery = args.query.toHttpUrlOrNull()?.let {
val videoId = TextUtils.getVideoIdFromUrl(it.toString()) ?: args.query
"${ShareDialog.YOUTUBE_FRONTEND_URL}/watch?v=$videoId"
} ?: args.query
private val filterMutableData = MutableStateFlow("all")
@OptIn(ExperimentalCoroutinesApi::class)
val searchResultsFlow = filterMutableData.flatMapLatest {
Pager(
PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { SearchPagingSource(searchQuery, it) }
).flow
}
.cachedIn(viewModelScope)
fun setFilter(filter: String) {
filterMutableData.value = filter
}
}

View File

@ -0,0 +1,26 @@
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.ContentItem
import com.github.libretube.util.deArrow
import retrofit2.HttpException
class SearchPagingSource(
private val searchQuery: String,
private val searchFilter: String
): PagingSource<String, ContentItem>() {
override fun getRefreshKey(state: PagingState<String, ContentItem>) = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, ContentItem> {
return try {
val result = params.key?.let {
RetrofitInstance.api.getSearchResultsNextPage(searchQuery, searchFilter, it)
} ?: RetrofitInstance.api.getSearchResults(searchQuery, searchFilter)
LoadResult.Page(result.items.deArrow(), null, result.nextpage)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
}

View File

@ -29,6 +29,7 @@ uiautomator = "2.2.0"
benchmarkMacroJunit4 = "1.2.3"
baselineprofile = "1.2.3"
profileinstaller = "1.3.1"
paging = "3.2.1"
[libraries]
androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
@ -72,6 +73,7 @@ kotlinx-serialization-retrofit = { group = "com.jakewharton.retrofit", name = "r
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
androidx-paging = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
[plugins]
androidTest = { id = "com.android.test", version.ref = "gradle" }