From 16c2cd634d72da319fd6f0ce65d117703b171e7b Mon Sep 17 00:00:00 2001 From: Jarne Demeulemeester <32322857+jarnedemeulemeester@users.noreply.github.com> Date: Fri, 17 Jun 2022 15:16:29 +0200 Subject: [PATCH] Add paging support to LibraryFragment (#124) * Add paging support to the LibraryFragment * Fix error handling --- app/build.gradle.kts | 3 + .../adapters/ViewItemPagingAdapter.kt | 69 +++++++++++++++++++ .../jellyfin/fragments/LibraryFragment.kt | 30 ++++++-- .../jellyfin/repository/ItemsPagingSource.kt | 50 ++++++++++++++ .../jellyfin/repository/JellyfinRepository.kt | 10 +++ .../repository/JellyfinRepositoryImpl.kt | 42 ++++++++++- .../jellyfin/viewmodels/LibraryViewModel.kt | 9 ++- 7 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemPagingAdapter.kt create mode 100644 app/src/main/java/dev/jdtech/jellyfin/repository/ItemsPagingSource.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 271e722e..a3d4356b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -124,4 +124,7 @@ dependencies { val aboutLibrariesVersion = "10.3.0" implementation("com.mikepenz:aboutlibraries-core:$aboutLibrariesVersion") implementation("com.mikepenz:aboutlibraries:$aboutLibrariesVersion") + + val pagingVersion = "3.1.1" + implementation("androidx.paging:paging-runtime-ktx:$pagingVersion") } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemPagingAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemPagingAdapter.kt new file mode 100644 index 00000000..1cc5a7b8 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemPagingAdapter.kt @@ -0,0 +1,69 @@ +package dev.jdtech.jellyfin.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import dev.jdtech.jellyfin.R +import dev.jdtech.jellyfin.databinding.BaseItemBinding +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind + +class ViewItemPagingAdapter( + private val onClickListener: OnClickListener, + private val fixedWidth: Boolean = false, +) : PagingDataAdapter(DiffCallback) { + + class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) : + RecyclerView.ViewHolder(binding.root) { + fun bind(item: BaseItemDto, fixedWidth: Boolean) { + binding.item = item + binding.itemName.text = + if (item.type == BaseItemKind.EPISODE) item.seriesName else item.name + binding.itemCount.visibility = + if (item.userData?.unplayedItemCount != null && item.userData?.unplayedItemCount!! > 0) View.VISIBLE else View.GONE + if (fixedWidth) { + binding.itemLayout.layoutParams.width = + parent.resources.getDimension(R.dimen.overview_media_width).toInt() + (binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0 + } + binding.executePendingBindings() + } + } + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { + return oldItem == newItem + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { + return ItemViewHolder( + BaseItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), parent + ) + } + + override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { + val item = getItem(position) + if (item != null) { + holder.itemView.setOnClickListener { + onClickListener.onClick(item) + } + holder.bind(item, fixedWidth) + } + } + + class OnClickListener(val clickListener: (item: BaseItemDto) -> Unit) { + fun onClick(item: BaseItemDto) = clickListener(item) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt index f0e9fb34..f6584882 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt @@ -11,10 +11,11 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs +import androidx.paging.LoadState import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.viewmodels.LibraryViewModel -import dev.jdtech.jellyfin.adapters.ViewItemListAdapter +import dev.jdtech.jellyfin.adapters.ViewItemPagingAdapter import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.SortDialogFragment @@ -91,10 +92,25 @@ class LibraryFragment : Fragment() { } binding.itemsRecyclerView.adapter = - ViewItemListAdapter(ViewItemListAdapter.OnClickListener { item -> + ViewItemPagingAdapter(ViewItemPagingAdapter.OnClickListener { item -> navigateToMediaInfoFragment(item) }) + (binding.itemsRecyclerView.adapter as ViewItemPagingAdapter).addLoadStateListener { + when (it.refresh) { + is LoadState.Error -> { + val error = Exception((it.refresh as LoadState.Error).error) + bindUiStateError(LibraryViewModel.UiState.Error(error)) + } + is LoadState.Loading -> { + bindUiStateLoading() + } + is LoadState.NotLoading -> { + binding.loadingIndicator.isVisible = false + } + } + } + viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> @@ -119,8 +135,14 @@ class LibraryFragment : Fragment() { } private fun bindUiStateNormal(uiState: LibraryViewModel.UiState.Normal) { - val adapter = binding.itemsRecyclerView.adapter as ViewItemListAdapter - adapter.submitList(uiState.items) + val adapter = binding.itemsRecyclerView.adapter as ViewItemPagingAdapter + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + uiState.items.collect { + adapter.submitData(it) + } + } + } binding.loadingIndicator.isVisible = false binding.itemsRecyclerView.isVisible = true binding.errorLayout.errorPanel.isVisible = false diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/ItemsPagingSource.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/ItemsPagingSource.kt new file mode 100644 index 00000000..0939b88d --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/repository/ItemsPagingSource.kt @@ -0,0 +1,50 @@ +package dev.jdtech.jellyfin.repository + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import dev.jdtech.jellyfin.api.JellyfinApi +import dev.jdtech.jellyfin.utils.SortBy +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.SortOrder +import timber.log.Timber +import java.util.* + +class ItemsPagingSource( + private val jellyfinApi: JellyfinApi, + private val parentId: UUID?, + private val includeTypes: List?, + private val recursive: Boolean, + private val sortBy: SortBy, + private val sortOrder: SortOrder +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + val position = params.key ?: 0 + + Timber.d("Retrieving position: $position") + + return try { + val response = jellyfinApi.itemsApi.getItems( + jellyfinApi.userId!!, + parentId = parentId, + includeItemTypes = includeTypes, + recursive = recursive, + sortBy = listOf(sortBy.SortString), + sortOrder = listOf(sortOrder), + startIndex = position, + limit = params.loadSize + ).content.items.orEmpty() + LoadResult.Page( + data = response, + prevKey = if (position == 0) null else position - params.loadSize, + nextKey = if (response.isEmpty()) null else position + params.loadSize + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int { + return 0 + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt index 0c5ba0f8..f7375c60 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt @@ -1,7 +1,9 @@ package dev.jdtech.jellyfin.repository +import androidx.paging.PagingData import dev.jdtech.jellyfin.utils.SortBy +import kotlinx.coroutines.flow.Flow import org.jellyfin.sdk.model.api.* import java.util.* @@ -18,6 +20,14 @@ interface JellyfinRepository { sortOrder: SortOrder = SortOrder.ASCENDING ): List + suspend fun getItemsPaging( + parentId: UUID? = null, + includeTypes: List? = null, + recursive: Boolean = false, + sortBy: SortBy = SortBy.defaultValue, + sortOrder: SortOrder = SortOrder.ASCENDING + ): Flow> + suspend fun getPersonItems( personIds: List, includeTypes: List? = null, diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt index d1edacbd..c98eb613 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt @@ -1,8 +1,12 @@ package dev.jdtech.jellyfin.repository +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.utils.SortBy import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.* import timber.log.Timber @@ -34,6 +38,32 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep ).content.items.orEmpty() } + override suspend fun getItemsPaging( + parentId: UUID?, + includeTypes: List?, + recursive: Boolean, + sortBy: SortBy, + sortOrder: SortOrder + ): Flow> { + return Pager( + config = PagingConfig( + pageSize = 10, + maxSize = 100, + enablePlaceholders = false + ), + pagingSourceFactory = { + ItemsPagingSource( + jellyfinApi, + parentId, + includeTypes, + recursive, + sortBy, + sortOrder + ) + } + ).flow + } + override suspend fun getPersonItems( personIds: List, includeTypes: List?, @@ -51,7 +81,11 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep jellyfinApi.itemsApi.getItems( jellyfinApi.userId!!, filters = listOf(ItemFilter.IS_FAVORITE), - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE), + includeItemTypes = listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE + ), recursive = true ).content.items.orEmpty() } @@ -61,7 +95,11 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep jellyfinApi.itemsApi.getItems( jellyfinApi.userId!!, searchTerm = searchQuery, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE), + includeItemTypes = listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE + ), recursive = true ).content.items.orEmpty() } diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt index 0380fd94..372072cb 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt @@ -1,9 +1,11 @@ package dev.jdtech.jellyfin.viewmodels import androidx.lifecycle.* +import androidx.paging.PagingData import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.utils.SortBy +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto @@ -22,7 +24,7 @@ constructor( private val uiState = MutableStateFlow(UiState.Loading) sealed class UiState { - data class Normal(val items: List) : UiState() + data class Normal(val items: Flow>) : UiState() object Loading : UiState() data class Error(val error: Exception) : UiState() } @@ -46,8 +48,9 @@ constructor( viewModelScope.launch { uiState.emit(UiState.Loading) try { - val items = jellyfinRepository.getItems( - parentId, + + val items = jellyfinRepository.getItemsPaging( + parentId = parentId, includeTypes = if (itemType != null) listOf(itemType) else null, recursive = true, sortBy = sortBy,