From 2356bf5d6b7b766f678d92eabdf7da75b3a3b1a8 Mon Sep 17 00:00:00 2001 From: Jarne Demeulemeester <32322857+jarnedemeulemeester@users.noreply.github.com> Date: Sat, 28 Jan 2023 21:07:45 +0100 Subject: [PATCH] Implement collections (#252) * Implement collections * Set collection name in top app bar --- .../jellyfin/fragments/CollectionFragment.kt | 123 ++++++++++++++++++ .../jellyfin/fragments/LibraryFragment.kt | 16 ++- .../main/res/navigation/app_navigation.xml | 27 ++++ .../jdtech/jellyfin/models/CollectionType.kt | 2 +- .../viewmodels/CollectionViewModel.kt | 86 ++++++++++++ .../jellyfin/viewmodels/LibraryViewModel.kt | 7 +- 6 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/fragments/CollectionFragment.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/CollectionViewModel.kt diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/CollectionFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/CollectionFragment.kt new file mode 100644 index 00000000..0da20fcc --- /dev/null +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/CollectionFragment.kt @@ -0,0 +1,123 @@ +package dev.jdtech.jellyfin.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import dev.jdtech.jellyfin.adapters.FavoritesListAdapter +import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter +import dev.jdtech.jellyfin.adapters.ViewItemListAdapter +import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding +import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.utils.checkIfLoginRequired +import dev.jdtech.jellyfin.viewmodels.CollectionViewModel +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber + +@AndroidEntryPoint +class CollectionFragment : Fragment() { + private lateinit var binding: FragmentFavoriteBinding + private val viewModel: CollectionViewModel by viewModels() + private val args: CollectionFragmentArgs by navArgs() + + private lateinit var errorDialog: ErrorDialogFragment + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentFavoriteBinding.inflate(inflater, container, false) + + binding.favoritesRecyclerView.adapter = FavoritesListAdapter( + ViewItemListAdapter.OnClickListener { item -> + navigateToMediaInfoFragment(item) + }, + HomeEpisodeListAdapter.OnClickListener { item -> + navigateToEpisodeBottomSheetFragment(item) + } + ) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { uiState -> + Timber.d("$uiState") + when (uiState) { + is CollectionViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is CollectionViewModel.UiState.Loading -> bindUiStateLoading() + is CollectionViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.loadItems(args.collectionId) + } + } + + binding.errorLayout.errorRetryButton.setOnClickListener { + viewModel.loadItems(args.collectionId) + } + + binding.errorLayout.errorDetailsButton.setOnClickListener { + errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG) + } + + return binding.root + } + + private fun bindUiStateNormal(uiState: CollectionViewModel.UiState.Normal) { + uiState.apply { + binding.noFavoritesText.isVisible = collectionSections.isEmpty() + + val adapter = binding.favoritesRecyclerView.adapter as FavoritesListAdapter + adapter.submitList(collectionSections) + } + binding.loadingIndicator.isVisible = false + binding.favoritesRecyclerView.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: CollectionViewModel.UiState.Error) { + errorDialog = ErrorDialogFragment.newInstance(uiState.error) + binding.loadingIndicator.isVisible = false + binding.favoritesRecyclerView.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(uiState.error.message) + } + + private fun navigateToMediaInfoFragment(item: BaseItemDto) { + findNavController().navigate( + CollectionFragmentDirections.actionCollectionFragmentToMediaInfoFragment( + item.id, + item.name, + item.type + ) + ) + } + + private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) { + findNavController().navigate( + CollectionFragmentDirections.actionCollectionFragmentToEpisodeBottomSheetFragment( + episode.id + ) + ) + } +} \ No newline at end of file diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt index 262e59ff..dac20f81 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt @@ -25,6 +25,7 @@ import dev.jdtech.jellyfin.adapters.ViewItemPagingAdapter import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.SortDialogFragment +import dev.jdtech.jellyfin.models.CollectionType import dev.jdtech.jellyfin.models.SortBy import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.LibraryViewModel @@ -112,7 +113,11 @@ class LibraryFragment : Fragment() { binding.itemsRecyclerView.adapter = ViewItemPagingAdapter( ViewItemPagingAdapter.OnClickListener { item -> - navigateToMediaInfoFragment(item) + if (args.libraryType == CollectionType.BoxSets.type) { + navigateToCollectionFragment(item) + } else { + navigateToMediaInfoFragment(item) + } } ) @@ -199,4 +204,13 @@ class LibraryFragment : Fragment() { ) ) } + + private fun navigateToCollectionFragment(collection: BaseItemDto) { + findNavController().navigate( + LibraryFragmentDirections.actionLibraryFragmentToCollectionFragment( + collection.id, + collection.name + ) + ) + } } diff --git a/app/phone/src/main/res/navigation/app_navigation.xml b/app/phone/src/main/res/navigation/app_navigation.xml index c9d6c6c2..4b259ce8 100644 --- a/app/phone/src/main/res/navigation/app_navigation.xml +++ b/app/phone/src/main/res/navigation/app_navigation.xml @@ -102,6 +102,13 @@ app:exitAnim="@anim/nav_default_exit_anim" app:popEnterAnim="@anim/nav_default_pop_enter_anim" app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + + + + + + + (UiState.Loading) + val uiState = _uiState.asStateFlow() + + sealed class UiState { + data class Normal(val collectionSections: List) : UiState() + object Loading : UiState() + data class Error(val error: Exception) : UiState() + } + + fun loadItems(parentId: UUID) { + viewModelScope.launch { + _uiState.emit(UiState.Loading) + + try { + val items = jellyfinRepository.getItems(parentId = parentId) + + if (items.isEmpty()) { + _uiState.emit(UiState.Normal(emptyList())) + return@launch + } + + val favoriteSections = mutableListOf() + + withContext(Dispatchers.Default) { + FavoriteSection( + Constants.FAVORITE_TYPE_MOVIES, + UiText.StringResource(R.string.movies_label), + items.filter { it.type == BaseItemKind.MOVIE } + ).let { + if (it.items.isNotEmpty()) favoriteSections.add( + it + ) + } + FavoriteSection( + Constants.FAVORITE_TYPE_SHOWS, + UiText.StringResource(R.string.shows_label), + items.filter { it.type == BaseItemKind.SERIES } + ).let { + if (it.items.isNotEmpty()) favoriteSections.add( + it + ) + } + FavoriteSection( + Constants.FAVORITE_TYPE_EPISODES, + UiText.StringResource(R.string.episodes_label), + items.filter { it.type == BaseItemKind.EPISODE } + ).let { + if (it.items.isNotEmpty()) favoriteSections.add( + it + ) + } + } + + _uiState.emit(UiState.Normal(favoriteSections)) + } catch (e: Exception) { + _uiState.emit(UiState.Error(e)) + } + } + + } +} \ No newline at end of file diff --git a/core/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt b/core/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt index 3cb3938a..a6c927e9 100644 --- a/core/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt +++ b/core/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt @@ -40,8 +40,9 @@ constructor( ) { Timber.d("$libraryType") val itemType = when (libraryType) { - "movies" -> BaseItemKind.MOVIE - "tvshows" -> BaseItemKind.SERIES + "movies" -> listOf(BaseItemKind.MOVIE) + "tvshows" -> listOf(BaseItemKind.SERIES) + "boxsets" -> listOf(BaseItemKind.BOX_SET) else -> null } viewModelScope.launch { @@ -49,7 +50,7 @@ constructor( try { val items = jellyfinRepository.getItemsPaging( parentId = parentId, - includeTypes = if (itemType != null) listOf(itemType) else null, + includeTypes = itemType, recursive = true, sortBy = sortBy, sortOrder = sortOrder