From c645ee3b8162151de25268a479aa7cfd60288aa1 Mon Sep 17 00:00:00 2001 From: Jarne Demeulemeester <32322857+jarnedemeulemeester@users.noreply.github.com> Date: Sun, 19 Dec 2021 15:35:36 +0100 Subject: [PATCH] New UI state system (#71) * Convert MediaFragment to use new UiState * Convert PersonDetailFragment to use new UiState * Load PersonDetail data on start * Convert FavoriteFragment to use new UiState * Convert SeasonFragment to use new UiState * Convert SearchResultFragment to use new UiState * Convert EpisodeBottomSheetFragment to use new UiState (WIP) * Convert EpisodeBottomSheetFragment to use new UiState (Part 2) * Convert LibraryFragment to use new UiState * Convert DownloadFragment to use new UiState * Convert HomeFragment to use new UiState * Convert MediaInfoFragment to use new UiState (WIP) * Convert MediaInfoViewModel to use new UiState (Part 2) * Convert ServerSelectViewModel to use new UiState (Semi) * Fix MediaInfoFragment for downloaded movies --- .../dev/jdtech/jellyfin/BindingAdapters.kt | 35 - .../dev/jdtech/jellyfin/MainActivityTv.kt | 3 + .../jellyfin/fragments/DownloadFragment.kt | 76 +- .../fragments/EpisodeBottomSheetFragment.kt | 169 ++-- .../jellyfin/fragments/FavoriteFragment.kt | 70 +- .../jdtech/jellyfin/fragments/HomeFragment.kt | 86 +- .../jellyfin/fragments/LibraryFragment.kt | 79 +- .../jellyfin/fragments/MediaFragment.kt | 62 +- .../jellyfin/fragments/MediaInfoFragment.kt | 214 +++-- .../fragments/PersonDetailFragment.kt | 88 +- .../fragments/SearchResultFragment.kt | 73 +- .../jellyfin/fragments/SeasonFragment.kt | 63 +- .../fragments/ServerSelectFragment.kt | 17 +- .../jdtech/jellyfin/models/CollectionType.kt | 6 +- .../dev/jdtech/jellyfin/tv/ui/HomeFragment.kt | 22 +- .../jellyfin/tv/ui/MediaDetailFragment.kt | 192 +++-- .../jellyfin/tv/ui/MediaDetailViewModel.kt | 38 +- .../jellyfin/utils/DownloadUtilities.kt | 5 +- .../jellyfin/viewmodels/DownloadViewModel.kt | 71 +- .../viewmodels/EpisodeBottomSheetViewModel.kt | 128 +-- .../jellyfin/viewmodels/FavoriteViewModel.kt | 41 +- .../jellyfin/viewmodels/HomeViewModel.kt | 54 +- .../jellyfin/viewmodels/LibraryViewModel.kt | 32 +- .../jellyfin/viewmodels/MediaInfoViewModel.kt | 208 +++-- .../jellyfin/viewmodels/MediaViewModel.kt | 39 +- .../viewmodels/PersonDetailViewModel.kt | 42 +- .../viewmodels/SearchResultViewModel.kt | 41 +- .../jellyfin/viewmodels/SeasonViewModel.kt | 41 +- .../viewmodels/ServerSelectViewModel.kt | 25 +- .../media_detail_fragment.xml | 515 +++++------ .../main/res/layout/episode_bottom_sheet.xml | 452 +++++----- app/src/main/res/layout/fragment_download.xml | 97 +-- app/src/main/res/layout/fragment_favorite.xml | 95 +- app/src/main/res/layout/fragment_home.xml | 87 +- app/src/main/res/layout/fragment_library.xml | 80 +- app/src/main/res/layout/fragment_media.xml | 81 +- .../main/res/layout/fragment_media_info.xml | 814 +++++++++--------- .../res/layout/fragment_person_detail.xml | 268 +++--- .../res/layout/fragment_search_result.xml | 90 +- app/src/main/res/layout/fragment_season.xml | 68 +- 40 files changed, 2396 insertions(+), 2271 deletions(-) diff --git a/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt b/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt index 0805f6d8..42a7de62 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt @@ -6,11 +6,7 @@ import androidx.databinding.BindingAdapter import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions -import dev.jdtech.jellyfin.adapters.CollectionListAdapter import dev.jdtech.jellyfin.adapters.DownloadsListAdapter -import dev.jdtech.jellyfin.adapters.EpisodeItem -import dev.jdtech.jellyfin.adapters.EpisodeListAdapter -import dev.jdtech.jellyfin.adapters.FavoritesListAdapter import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter import dev.jdtech.jellyfin.adapters.HomeItem import dev.jdtech.jellyfin.adapters.PersonListAdapter @@ -20,7 +16,6 @@ import dev.jdtech.jellyfin.adapters.ViewListAdapter import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.database.Server import dev.jdtech.jellyfin.models.DownloadSection -import dev.jdtech.jellyfin.models.FavoriteSection import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.ImageType @@ -32,12 +27,6 @@ fun bindServers(recyclerView: RecyclerView, data: List?) { adapter.submitList(data) } -@BindingAdapter("views") -fun bindViews(recyclerView: RecyclerView, data: List?) { - val adapter = recyclerView.adapter as ViewListAdapter - adapter.submitList(data) -} - @BindingAdapter("items") fun bindItems(recyclerView: RecyclerView, data: List?) { val adapter = recyclerView.adapter as ViewItemListAdapter @@ -68,12 +57,6 @@ fun bindItemBackdropById(imageView: ImageView, itemId: UUID) { imageView.loadImage("/items/$itemId/Images/${ImageType.BACKDROP}") } -@BindingAdapter("collections") -fun bindCollections(recyclerView: RecyclerView, data: List?) { - val adapter = recyclerView.adapter as CollectionListAdapter - adapter.submitList(data) -} - @BindingAdapter("people") fun bindPeople(recyclerView: RecyclerView, data: List?) { val adapter = recyclerView.adapter as PersonListAdapter @@ -87,12 +70,6 @@ fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) { .posterDescription(person.name) } -@BindingAdapter("episodes") -fun bindEpisodes(recyclerView: RecyclerView, data: List?) { - val adapter = recyclerView.adapter as EpisodeListAdapter - adapter.submitList(data) -} - @BindingAdapter("homeEpisodes") fun bindHomeEpisodes(recyclerView: RecyclerView, data: List?) { val adapter = recyclerView.adapter as HomeEpisodeListAdapter @@ -136,18 +113,6 @@ fun bindSeasonPoster(imageView: ImageView, seasonId: UUID) { imageView.loadImage("/items/${seasonId}/Images/${ImageType.PRIMARY}") } -@BindingAdapter("favoriteSections") -fun bindFavoriteSections(recyclerView: RecyclerView, data: List?) { - val adapter = recyclerView.adapter as FavoritesListAdapter - adapter.submitList(data) -} - -@BindingAdapter("downloadSections") -fun bindDownloadSections(recyclerView: RecyclerView, data: List?) { - val adapter = recyclerView.adapter as DownloadsListAdapter - adapter.submitList(data) -} - private fun ImageView.loadImage(url: String, errorPlaceHolderId: Int? = null): View { val api = JellyfinApi.getInstance(context.applicationContext) diff --git a/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt b/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt index ef50f30a..f67222bf 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt @@ -7,6 +7,7 @@ import androidx.navigation.fragment.NavHostFragment import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.databinding.ActivityMainTvBinding import dev.jdtech.jellyfin.tv.ui.HomeFragmentDirections +import dev.jdtech.jellyfin.utils.loadDownloadLocation import dev.jdtech.jellyfin.viewmodels.MainViewModel @AndroidEntryPoint @@ -24,6 +25,8 @@ internal class MainActivityTv : FragmentActivity() { supportFragmentManager.findFragmentById(R.id.tv_nav_host) as NavHostFragment val navController = navHostFragment.navController + loadDownloadLocation(applicationContext) + viewModel.navigateToAddServer.observe(this, { if (it) { navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment()) diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt index 96fdcd9d..6f638f84 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt @@ -4,21 +4,23 @@ 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 dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.R -import dev.jdtech.jellyfin.adapters.DownloadEpisodeListAdapter -import dev.jdtech.jellyfin.adapters.DownloadViewItemListAdapter -import dev.jdtech.jellyfin.adapters.DownloadsListAdapter -import dev.jdtech.jellyfin.adapters.ViewItemListAdapter +import dev.jdtech.jellyfin.adapters.* import dev.jdtech.jellyfin.databinding.FragmentDownloadBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.DownloadViewModel -import org.jellyfin.sdk.model.api.BaseItemDto +import kotlinx.coroutines.launch +import timber.log.Timber import java.util.* @AndroidEntryPoint @@ -27,14 +29,14 @@ class DownloadFragment : Fragment() { private lateinit var binding: FragmentDownloadBinding private val viewModel: DownloadViewModel by viewModels() + private lateinit var errorDialog: ErrorDialogFragment + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentDownloadBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel binding.downloadsRecyclerView.adapter = DownloadsListAdapter( DownloadViewItemListAdapter.OnClickListener { item -> navigateToMediaInfoFragment(item) @@ -42,40 +44,56 @@ class DownloadFragment : Fragment() { navigateToEpisodeBottomSheetFragment(item) }) - viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished -> - binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE - }) - - viewModel.error.observe(viewLifecycleOwner, { error -> - if (error != null) { - checkIfLoginRequired(error) - binding.errorLayout.errorPanel.visibility = View.VISIBLE - binding.downloadsRecyclerView.visibility = View.GONE - } else { - binding.errorLayout.errorPanel.visibility = View.GONE - binding.downloadsRecyclerView.visibility = View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is DownloadViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is DownloadViewModel.UiState.Loading -> bindUiStateLoading() + is DownloadViewModel.UiState.Error -> bindUiStateError(uiState) + } + } } - }) + } binding.errorLayout.errorRetryButton.setOnClickListener { viewModel.loadData() } binding.errorLayout.errorDetailsButton.setOnClickListener { - ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") + errorDialog.show(parentFragmentManager, "errordialog") } - viewModel.downloadSections.observe(viewLifecycleOwner, { sections -> - if (sections.isEmpty()) { - binding.noDownloadsText.visibility = View.VISIBLE - } else { - binding.noDownloadsText.visibility = View.GONE - } - }) - return binding.root } + private fun bindUiStateNormal(uiState: DownloadViewModel.UiState.Normal) { + uiState.apply { + binding.noDownloadsText.isVisible = downloadSections.isEmpty() + + val adapter = binding.downloadsRecyclerView.adapter as DownloadsListAdapter + adapter.submitList(downloadSections) + } + binding.loadingIndicator.isVisible = false + binding.downloadsRecyclerView.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: DownloadViewModel.UiState.Error) { + val error = uiState.message ?: resources.getString(R.string.unknown_error) + errorDialog = ErrorDialogFragment(error) + binding.loadingIndicator.isVisible = false + binding.downloadsRecyclerView.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(error) + } + private fun navigateToMediaInfoFragment(item: PlayerItem) { findNavController().navigate( DownloadFragmentDirections.actionDownloadFragmentToMediaInfoFragment( diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt index dffa0c9a..7c71a9d4 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt @@ -1,6 +1,5 @@ package dev.jdtech.jellyfin.fragments -import android.net.Uri import android.os.Bundle import android.util.TypedValue import android.view.LayoutInflater @@ -9,18 +8,22 @@ import android.view.ViewGroup import androidx.core.content.ContextCompat 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.findNavController import androidx.navigation.fragment.navArgs import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.R +import dev.jdtech.jellyfin.bindBaseItemImage import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.models.PlayerItem -import dev.jdtech.jellyfin.utils.requestDownload import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.LocationType import timber.log.Timber import java.util.* @@ -39,13 +42,10 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { ): View { binding = EpisodeBottomSheetBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel - binding.playButton.setOnClickListener { binding.playButton.setImageResource(android.R.color.transparent) - binding.progressCircular.visibility = View.VISIBLE - viewModel.item.value?.let { + binding.progressCircular.isVisible = true + viewModel.item?.let { if (!args.isOffline) { playerViewModel.loadPlayerItems(it) } else { @@ -54,6 +54,19 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { } } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is EpisodeBottomSheetViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is EpisodeBottomSheetViewModel.UiState.Loading -> bindUiStateLoading() + is EpisodeBottomSheetViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + } + } + playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> when (playerItems) { is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) @@ -61,79 +74,46 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { } } - viewModel.item.observe(viewLifecycleOwner, { episode -> - if (episode.userData?.playedPercentage != null) { - binding.progressBar.layoutParams.width = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - (episode.userData?.playedPercentage?.times(1.26))!!.toFloat(), - context?.resources?.displayMetrics - ).toInt() - binding.progressBar.visibility = View.VISIBLE - } - binding.communityRating.visibility = when (episode.communityRating != null) { - false -> View.GONE - true -> View.VISIBLE - } - }) - - viewModel.played.observe(viewLifecycleOwner, { - val drawable = when (it) { - true -> R.drawable.ic_check_filled - false -> R.drawable.ic_check - } - - binding.checkButton.setImageResource(drawable) - }) - - viewModel.favorite.observe(viewLifecycleOwner, { - val drawable = when (it) { - true -> R.drawable.ic_heart_filled - false -> R.drawable.ic_heart - } - - binding.favoriteButton.setImageResource(drawable) - }) - - viewModel.downloaded.observe(viewLifecycleOwner, { - val drawable = when (it) { - true -> R.drawable.ic_download_filled - false -> R.drawable.ic_download - } - - binding.downloadButton.setImageResource(drawable) - }) - - viewModel.downloadEpisode.observe(viewLifecycleOwner, { - if (it) { - requestDownload(Uri.parse(viewModel.downloadRequestItem.uri), viewModel.downloadRequestItem, this) - viewModel.doneDownloadEpisode() - } - }) - - if(!args.isOffline){ + if(!args.isOffline) { val episodeId: UUID = args.episodeId + binding.checkButton.setOnClickListener { - when (viewModel.played.value) { - true -> viewModel.markAsUnplayed(episodeId) - false -> viewModel.markAsPlayed(episodeId) + when (viewModel.played) { + true -> { + viewModel.markAsUnplayed(episodeId) + binding.checkButton.setImageResource(R.drawable.ic_check) + } + false -> { + viewModel.markAsPlayed(episodeId) + binding.checkButton.setImageResource(R.drawable.ic_check_filled) + } } } binding.favoriteButton.setOnClickListener { - when (viewModel.favorite.value) { - true -> viewModel.unmarkAsFavorite(episodeId) - false -> viewModel.markAsFavorite(episodeId) + when (viewModel.favorite) { + true -> { + viewModel.unmarkAsFavorite(episodeId) + binding.favoriteButton.setImageResource(R.drawable.ic_heart) + } + false -> { + viewModel.markAsFavorite(episodeId) + binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled) + } } } binding.downloadButton.setOnClickListener { + binding.downloadButton.isEnabled = false viewModel.loadDownloadRequestItem(episodeId) + binding.downloadButton.setImageResource(android.R.color.transparent) + binding.progressDownload.isVisible = true } - binding.deleteButton.visibility = View.GONE + binding.deleteButton.isVisible = false viewModel.loadEpisode(episodeId) - }else { + } else { val playerItem = args.playerItem!! viewModel.loadEpisode(playerItem) @@ -143,14 +123,67 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { findNavController().navigate(R.id.downloadFragment) } - binding.checkButton.visibility = View.GONE - binding.favoriteButton.visibility = View.GONE - binding.downloadButton.visibility = View.GONE + binding.checkButton.isVisible = false + binding.favoriteButton.isVisible = false + binding.downloadButtonWrapper.isVisible = false } return binding.root } + private fun bindUiStateNormal(uiState: EpisodeBottomSheetViewModel.UiState.Normal) { + uiState.apply { + if (episode.userData?.playedPercentage != null) { + binding.progressBar.layoutParams.width = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + (episode.userData?.playedPercentage?.times(1.26))!!.toFloat(), + context?.resources?.displayMetrics + ).toInt() + binding.progressBar.isVisible = true + } + + // Check icon + val checkDrawable = when (played) { + true -> R.drawable.ic_check_filled + false -> R.drawable.ic_check + } + binding.checkButton.setImageResource(checkDrawable) + + // Favorite icon + val favoriteDrawable = when (favorite) { + true -> R.drawable.ic_heart_filled + false -> R.drawable.ic_heart + } + binding.favoriteButton.setImageResource(favoriteDrawable) + + // Download icon + val downloadDrawable = when (downloaded) { + true -> R.drawable.ic_download_filled + false -> R.drawable.ic_download + } + binding.downloadButton.setImageResource(downloadDrawable) + + binding.episodeName.text = String.format(getString(R.string.episode_name_extended), episode.parentIndexNumber, episode.indexNumber, episode.name) + binding.overview.text = episode.overview + binding.year.text = dateString + binding.playtime.text = runTime + binding.communityRating.isVisible = episode.communityRating != null + binding.communityRating.text = episode.communityRating.toString() + binding.missingIcon.isVisible = episode.locationType == LocationType.VIRTUAL + bindBaseItemImage(binding.episodeImage, episode) + } + binding.loadingIndicator.isVisible = false + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + } + + private fun bindUiStateError(uiState: EpisodeBottomSheetViewModel.UiState.Error) { + binding.loadingIndicator.isVisible = false + binding.overview.text = uiState.message + } + private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { navigateToPlayerActivity(items.items.toTypedArray()) binding.playButton.setImageDrawable( diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt index 768fd6a2..9bef3f8f 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt @@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +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.findNavController import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.R @@ -16,7 +20,9 @@ import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber @AndroidEntryPoint class FavoriteFragment : Fragment() { @@ -24,14 +30,14 @@ class FavoriteFragment : Fragment() { private lateinit var binding: FragmentFavoriteBinding private val viewModel: FavoriteViewModel by viewModels() + private lateinit var errorDialog: ErrorDialogFragment + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentFavoriteBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel binding.favoritesRecyclerView.adapter = FavoritesListAdapter( ViewItemListAdapter.OnClickListener { item -> navigateToMediaInfoFragment(item) @@ -39,40 +45,56 @@ class FavoriteFragment : Fragment() { navigateToEpisodeBottomSheetFragment(item) }) - viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished -> - binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE - }) - - viewModel.error.observe(viewLifecycleOwner, { error -> - if (error != null) { - checkIfLoginRequired(error) - binding.errorLayout.errorPanel.visibility = View.VISIBLE - binding.favoritesRecyclerView.visibility = View.GONE - } else { - binding.errorLayout.errorPanel.visibility = View.GONE - binding.favoritesRecyclerView.visibility = View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is FavoriteViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is FavoriteViewModel.UiState.Loading -> bindUiStateLoading() + is FavoriteViewModel.UiState.Error -> bindUiStateError(uiState) + } + } } - }) + } binding.errorLayout.errorRetryButton.setOnClickListener { viewModel.loadData() } binding.errorLayout.errorDetailsButton.setOnClickListener { - ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") + errorDialog.show(parentFragmentManager, "errordialog") } - viewModel.favoriteSections.observe(viewLifecycleOwner, { sections -> - if (sections.isEmpty()) { - binding.noFavoritesText.visibility = View.VISIBLE - } else { - binding.noFavoritesText.visibility = View.GONE - } - }) - return binding.root } + private fun bindUiStateNormal(uiState: FavoriteViewModel.UiState.Normal) { + uiState.apply { + binding.noFavoritesText.isVisible = favoriteSections.isEmpty() + + val adapter = binding.favoritesRecyclerView.adapter as FavoritesListAdapter + adapter.submitList(favoriteSections) + } + 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: FavoriteViewModel.UiState.Error) { + val error = uiState.message ?: resources.getString(R.string.unknown_error) + errorDialog = ErrorDialogFragment(error) + binding.loadingIndicator.isVisible = false + binding.favoritesRecyclerView.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(error) + } + private fun navigateToMediaInfoFragment(item: BaseItemDto) { findNavController().navigate( FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment( diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt index 2260629f..be0e9790 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt @@ -12,7 +12,9 @@ import android.widget.Toast.LENGTH_LONG 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 dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.R @@ -28,9 +30,9 @@ import dev.jdtech.jellyfin.models.ContentType.TVSHOW import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.contentType import dev.jdtech.jellyfin.viewmodels.HomeViewModel -import dev.jdtech.jellyfin.viewmodels.HomeViewModel.Loading -import dev.jdtech.jellyfin.viewmodels.HomeViewModel.LoadingError +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber @AndroidEntryPoint class HomeFragment : Fragment() { @@ -38,6 +40,8 @@ class HomeFragment : Fragment() { private lateinit var binding: FragmentHomeBinding private val viewModel: HomeViewModel by viewModels() + private lateinit var errorDialog: ErrorDialogFragment + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setHasOptionsMenu(true) @@ -66,9 +70,6 @@ class HomeFragment : Fragment() { ): View { binding = FragmentHomeBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel - setupView() bindState() @@ -84,6 +85,7 @@ class HomeFragment : Fragment() { private fun setupView() { binding.refreshLayout.setOnRefreshListener { viewModel.refreshData() + // binding.refreshLayout.isRefreshing = false } binding.viewsRecyclerView.adapter = ViewListAdapter( @@ -99,50 +101,56 @@ class HomeFragment : Fragment() { .show() } }) - } - - private fun bindState() { - viewModel.onStateUpdate(lifecycleScope) { state -> - when (state) { - is Loading -> bindLoading(state) - is LoadingError -> bindError(state) - } - } - } - - private fun bindError(state: LoadingError) { - checkIfLoginRequired(state.message) - binding.errorLayout.errorPanel.isVisible = true - binding.viewsRecyclerView.isVisible = false - binding.loadingIndicator.isVisible = false - binding.refreshLayout.isRefreshing = false - - binding.errorLayout.errorDetailsButton.setOnClickListener { - ErrorDialogFragment(state.message).show( - parentFragmentManager, - "errordialog" - ) - } binding.errorLayout.errorRetryButton.setOnClickListener { viewModel.refreshData() } + + binding.errorLayout.errorDetailsButton.setOnClickListener { + errorDialog.show(parentFragmentManager, "errordialog") + } } - private fun bindLoading(state: Loading) { - binding.errorLayout.errorPanel.isVisible = false - binding.viewsRecyclerView.isVisible = true - - binding.loadingIndicator.visibility = when { - state.inProgress && binding.refreshLayout.isRefreshing -> View.GONE - state.inProgress -> View.VISIBLE - else -> { - binding.refreshLayout.isRefreshing = false - View.GONE + private fun bindState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is HomeViewModel.UiState.Loading -> bindUiStateLoading() + is HomeViewModel.UiState.Error -> bindUiStateError(uiState) + } + } } } } + private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) { + uiState.apply { + val adapter = binding.viewsRecyclerView.adapter as ViewListAdapter + adapter.submitList(uiState.homeItems) + } + binding.loadingIndicator.isVisible = false + binding.refreshLayout.isRefreshing = false + binding.viewsRecyclerView.isVisible = true + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: HomeViewModel.UiState.Error) { + val error = uiState.message ?: getString(R.string.unknown_error) + errorDialog = ErrorDialogFragment(error) + binding.loadingIndicator.isVisible = false + binding.refreshLayout.isRefreshing = false + binding.viewsRecyclerView.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(error) + } + private fun navigateToLibraryFragment(view: dev.jdtech.jellyfin.models.View) { findNavController().navigate( HomeFragmentDirections.actionNavigationHomeToLibraryFragment( 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 f72442fe..68b9f75e 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt @@ -3,8 +3,12 @@ package dev.jdtech.jellyfin.fragments import android.content.SharedPreferences import android.os.Bundle import android.view.* +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 @@ -16,6 +20,7 @@ import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.SortDialogFragment import dev.jdtech.jellyfin.utils.SortBy import dev.jdtech.jellyfin.utils.checkIfLoginRequired +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.SortOrder import java.lang.IllegalArgumentException @@ -26,9 +31,10 @@ class LibraryFragment : Fragment() { private lateinit var binding: FragmentLibraryBinding private val viewModel: LibraryViewModel by viewModels() - private val args: LibraryFragmentArgs by navArgs() + private lateinit var errorDialog: ErrorDialogFragment + @Inject lateinit var sp: SharedPreferences @@ -67,56 +73,71 @@ class LibraryFragment : Fragment() { savedInstanceState: Bundle? ): View { binding = FragmentLibraryBinding.inflate(inflater, container, false) - - binding.lifecycleOwner = viewLifecycleOwner - return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.viewModel = viewModel - - viewModel.error.observe(viewLifecycleOwner, { error -> - if (error != null) { - checkIfLoginRequired(error) - binding.errorLayout.errorPanel.visibility = View.VISIBLE - binding.itemsRecyclerView.visibility = View.GONE - } else { - binding.errorLayout.errorPanel.visibility = View.GONE - binding.itemsRecyclerView.visibility = View.VISIBLE - } - }) binding.errorLayout.errorRetryButton.setOnClickListener { viewModel.loadItems(args.libraryId, args.libraryType) } binding.errorLayout.errorDetailsButton.setOnClickListener { - ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show( + errorDialog.show( parentFragmentManager, "errordialog" ) } - viewModel.finishedLoading.observe(viewLifecycleOwner, { - binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE - }) - binding.itemsRecyclerView.adapter = ViewItemListAdapter(ViewItemListAdapter.OnClickListener { item -> navigateToMediaInfoFragment(item) }) - // Sorting options - val sortBy = SortBy.fromString(sp.getString("sortBy", SortBy.defaultValue.name)!!) - val sortOrder = try { - SortOrder.valueOf(sp.getString("sortOrder", SortOrder.ASCENDING.name)!!) - } catch (e: IllegalArgumentException) { - SortOrder.ASCENDING - } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + when (uiState) { + is LibraryViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is LibraryViewModel.UiState.Loading -> bindUiStateLoading() + is LibraryViewModel.UiState.Error -> bindUiStateError(uiState) + } + } - viewModel.loadItems(args.libraryId, args.libraryType, sortBy = sortBy, sortOrder = sortOrder) + // Sorting options + val sortBy = SortBy.fromString(sp.getString("sortBy", SortBy.defaultValue.name)!!) + val sortOrder = try { + SortOrder.valueOf(sp.getString("sortOrder", SortOrder.ASCENDING.name)!!) + } catch (e: IllegalArgumentException) { + SortOrder.ASCENDING + } + + viewModel.loadItems(args.libraryId, args.libraryType, sortBy = sortBy, sortOrder = sortOrder) + } + } + } + + private fun bindUiStateNormal(uiState: LibraryViewModel.UiState.Normal) { + val adapter = binding.itemsRecyclerView.adapter as ViewItemListAdapter + adapter.submitList(uiState.items) + binding.loadingIndicator.isVisible = false + binding.itemsRecyclerView.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: LibraryViewModel.UiState.Error) { + val error = uiState.message ?: getString(R.string.unknown_error) + errorDialog = ErrorDialogFragment(error) + binding.loadingIndicator.isVisible = false + binding.itemsRecyclerView.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(error) } private fun navigateToMediaInfoFragment(item: BaseItemDto) { diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt index c9ffcb5e..ea8d94e0 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt @@ -3,8 +3,12 @@ package dev.jdtech.jellyfin.fragments import android.os.Bundle import android.view.* import androidx.appcompat.widget.SearchView +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 dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.R @@ -13,7 +17,9 @@ import dev.jdtech.jellyfin.databinding.FragmentMediaBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.MediaViewModel +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber @AndroidEntryPoint class MediaFragment : Fragment() { @@ -23,6 +29,8 @@ class MediaFragment : Fragment() { private var originalSoftInputMode: Int? = null + private lateinit var errorDialog: ErrorDialogFragment + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setHasOptionsMenu(true) @@ -56,37 +64,30 @@ class MediaFragment : Fragment() { ): View { binding = FragmentMediaBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel binding.viewsRecyclerView.adapter = CollectionListAdapter(CollectionListAdapter.OnClickListener { library -> navigateToLibraryFragment(library) }) - viewModel.finishedLoading.observe(viewLifecycleOwner, { - binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE - }) - - viewModel.error.observe(viewLifecycleOwner, { error -> - if (error != null) { - checkIfLoginRequired(error) - binding.errorLayout.errorPanel.visibility = View.VISIBLE - binding.viewsRecyclerView.visibility = View.GONE - } else { - binding.errorLayout.errorPanel.visibility = View.GONE - binding.viewsRecyclerView.visibility = View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is MediaViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is MediaViewModel.UiState.Loading -> bindUiStateLoading() + is MediaViewModel.UiState.Error -> bindUiStateError(uiState) + } + } } - }) + } binding.errorLayout.errorRetryButton.setOnClickListener { viewModel.loadData() } binding.errorLayout.errorDetailsButton.setOnClickListener { - ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show( - parentFragmentManager, - "errordialog" - ) + errorDialog.show(parentFragmentManager, "errordialog") } return binding.root @@ -105,6 +106,29 @@ class MediaFragment : Fragment() { originalSoftInputMode?.let { activity?.window?.setSoftInputMode(it) } } + private fun bindUiStateNormal(uiState: MediaViewModel.UiState.Normal) { + binding.loadingIndicator.isVisible = false + binding.viewsRecyclerView.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + val adapter = binding.viewsRecyclerView.adapter as CollectionListAdapter + adapter.submitList(uiState.collections) + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: MediaViewModel.UiState.Error) { + val error = uiState.message ?: resources.getString(R.string.unknown_error) + errorDialog = ErrorDialogFragment(error) + binding.loadingIndicator.isVisible = false + binding.viewsRecyclerView.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(error) + + } + private fun navigateToLibraryFragment(library: BaseItemDto) { findNavController().navigate( MediaFragmentDirections.actionNavigationMediaToLibraryFragment( diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt index 7746500e..e35c26e3 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt @@ -8,23 +8,28 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.core.content.ContextCompat +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.R import dev.jdtech.jellyfin.adapters.PersonListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter +import dev.jdtech.jellyfin.bindBaseItemImage +import dev.jdtech.jellyfin.bindItemBackdropImage import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.utils.checkIfLoginRequired -import dev.jdtech.jellyfin.utils.requestDownload import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.serializer.toUUID import timber.log.Timber @@ -36,35 +41,39 @@ class MediaInfoFragment : Fragment() { private lateinit var binding: FragmentMediaInfoBinding private val viewModel: MediaInfoViewModel by viewModels() private val playerViewModel: PlayerViewModel by viewModels() - private val args: MediaInfoFragmentArgs by navArgs() + lateinit var errorDialog: ErrorDialogFragment + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentMediaInfoBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.viewModel = viewModel - - viewModel.error.observe(viewLifecycleOwner, { error -> - if (error != null) { - checkIfLoginRequired(error) - binding.errorLayout.errorPanel.visibility = View.VISIBLE - binding.mediaInfoScrollview.visibility = View.GONE - } else { - binding.errorLayout.errorPanel.visibility = View.GONE - binding.mediaInfoScrollview.visibility = View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading() + is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + if (!args.isOffline) { + viewModel.loadData(args.itemId, args.itemType) + } else { + viewModel.loadData(args.playerItem!!) + } } - }) + } if(args.itemType != "Movie") { binding.downloadButton.visibility = View.GONE @@ -74,36 +83,6 @@ class MediaInfoFragment : Fragment() { viewModel.loadData(args.itemId, args.itemType) } - viewModel.downloadMedia.observe(viewLifecycleOwner, { - if (it) { - requestDownload(Uri.parse(viewModel.downloadRequestItem.uri), viewModel.downloadRequestItem, this) - viewModel.doneDownloadMedia() - } - }) - - viewModel.item.observe(viewLifecycleOwner, { item -> - if (item.originalTitle != item.name) { - binding.originalTitle.visibility = View.VISIBLE - } else { - binding.originalTitle.visibility = View.GONE - } - if (item.remoteTrailers.isNullOrEmpty()) { - binding.trailerButton.visibility = View.GONE - } - binding.communityRating.visibility = when (item.communityRating != null) { - true -> View.VISIBLE - false -> View.GONE - } - Timber.d(item.seasonId.toString()) - }) - - viewModel.actors.observe(viewLifecycleOwner, { actors -> - when (actors.isNullOrEmpty()) { - false -> binding.actors.visibility = View.VISIBLE - true -> binding.actors.visibility = View.GONE - } - }) - playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> when (playerItems) { is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) @@ -111,44 +90,17 @@ class MediaInfoFragment : Fragment() { } } - viewModel.played.observe(viewLifecycleOwner, { - val drawable = when (it) { - true -> R.drawable.ic_check_filled - false -> R.drawable.ic_check - } - - binding.checkButton.setImageResource(drawable) - }) - - viewModel.favorite.observe(viewLifecycleOwner, { - val drawable = when (it) { - true -> R.drawable.ic_heart_filled - false -> R.drawable.ic_heart - } - - binding.favoriteButton.setImageResource(drawable) - }) - - viewModel.downloaded.observe(viewLifecycleOwner, { - val drawable = when (it) { - true -> R.drawable.ic_download_filled - false -> R.drawable.ic_download - } - - binding.downloadButton.setImageResource(drawable) - }) - binding.trailerButton.setOnClickListener { - if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener + if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener val intent = Intent( Intent.ACTION_VIEW, - Uri.parse(viewModel.item.value?.remoteTrailers?.get(0)?.url) + Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url) ) startActivity(intent) } binding.nextUp.setOnClickListener { - navigateToEpisodeBottomSheetFragment(viewModel.nextUp.value!!) + navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!) } binding.seasonsRecyclerView.adapter = @@ -166,9 +118,8 @@ class MediaInfoFragment : Fragment() { binding.playButton.setOnClickListener { binding.playButton.setImageResource(android.R.color.transparent) - binding.progressCircular.visibility = View.VISIBLE - - viewModel.item.value?.let { item -> + binding.progressCircular.isVisible = true + viewModel.item?.let { item -> if (!args.isOffline) { playerViewModel.loadPlayerItems(item) { VideoVersionDialogFragment(item, playerViewModel).show( @@ -188,16 +139,28 @@ class MediaInfoFragment : Fragment() { } binding.checkButton.setOnClickListener { - when (viewModel.played.value) { - true -> viewModel.markAsUnplayed(args.itemId) - false -> viewModel.markAsPlayed(args.itemId) + when (viewModel.played) { + true -> { + viewModel.markAsUnplayed(args.itemId) + binding.checkButton.setImageResource(R.drawable.ic_check) + } + false -> { + viewModel.markAsPlayed(args.itemId) + binding.checkButton.setImageResource(R.drawable.ic_check_filled) + } } } binding.favoriteButton.setOnClickListener { - when (viewModel.favorite.value) { - true -> viewModel.unmarkAsFavorite(args.itemId) - false -> viewModel.markAsFavorite(args.itemId) + when (viewModel.favorite) { + true -> { + viewModel.unmarkAsFavorite(args.itemId) + binding.favoriteButton.setImageResource(R.drawable.ic_heart) + } + false -> { + viewModel.markAsFavorite(args.itemId) + binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled) + } } } @@ -205,23 +168,90 @@ class MediaInfoFragment : Fragment() { viewModel.loadDownloadRequestItem(args.itemId) } - binding.deleteButton.visibility = View.GONE - - viewModel.loadData(args.itemId, args.itemType) + binding.deleteButton.isVisible = false } else { - binding.favoriteButton.visibility = View.GONE - binding.checkButton.visibility = View.GONE - binding.downloadButton.visibility = View.GONE + binding.favoriteButton.isVisible = false + binding.checkButton.isVisible = false + binding.downloadButton.isVisible = false binding.deleteButton.setOnClickListener { viewModel.deleteItem() findNavController().navigate(R.id.downloadFragment) } - - viewModel.loadData(args.playerItem!!) } } + private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) { + uiState.apply { + binding.originalTitle.isVisible = item.originalTitle != item.name + if (item.remoteTrailers.isNullOrEmpty()) { + binding.trailerButton.isVisible = false + } + binding.communityRating.isVisible = item.communityRating != null + binding.actors.isVisible = actors.isNotEmpty() + + // Check icon + val checkDrawable = when (played) { + true -> R.drawable.ic_check_filled + false -> R.drawable.ic_check + } + binding.checkButton.setImageResource(checkDrawable) + + // Favorite icon + val favoriteDrawable = when (favorite) { + true -> R.drawable.ic_heart_filled + false -> R.drawable.ic_heart + } + binding.favoriteButton.setImageResource(favoriteDrawable) + + // Download icon + val downloadDrawable = when (downloaded) { + true -> R.drawable.ic_download_filled + false -> R.drawable.ic_download + } + binding.downloadButton.setImageResource(downloadDrawable) + binding.name.text = item.name + binding.originalTitle.text = item.originalTitle + if (dateString.isEmpty()) { + binding.year.isVisible = false + } else { + binding.year.text = dateString + } + if (runTime.isEmpty()) { + binding.playtime.isVisible = false + } else { + binding.playtime.text = runTime + } + binding.officialRating.text = item.officialRating + binding.communityRating.text = item.communityRating.toString() + binding.genresLayout.isVisible = item.genres?.isNotEmpty() ?: false + binding.genres.text = genresString + binding.directorLayout.isVisible = director != null + binding.director.text = director?.name + binding.writersLayout.isVisible = writers.isNotEmpty() + binding.writers.text = writersString + binding.description.text = item.overview + binding.nextUpLayout.isVisible = nextUp != null + binding.nextUpName.text = String.format(getString(R.string.episode_name_extended), nextUp?.parentIndexNumber, nextUp?.indexNumber, nextUp?.name) + binding.seasonsLayout.isVisible = seasons.isNotEmpty() + val seasonsAdapter = binding.seasonsRecyclerView.adapter as ViewItemListAdapter + seasonsAdapter.submitList(seasons) + val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter + actorsAdapter.submitList(actors) + bindItemBackdropImage(binding.itemBanner, item) + bindBaseItemImage(binding.nextUpImage, nextUp) + } + } + + private fun bindUiStateLoading() {} + + private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) { + val error = uiState.message ?: getString(R.string.unknown_error) + binding.mediaInfoScrollview.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(error) + } + private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { navigateToPlayerActivity(items.items.toTypedArray()) binding.playButton.setImageDrawable( diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt index 52f27b37..0e9a99eb 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt @@ -9,6 +9,9 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams 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 @@ -19,7 +22,9 @@ import dev.jdtech.jellyfin.databinding.FragmentPersonDetailBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber @AndroidEntryPoint internal class PersonDetailFragment : Fragment() { @@ -29,15 +34,14 @@ internal class PersonDetailFragment : Fragment() { private val args: PersonDetailFragmentArgs by navArgs() + private lateinit var errorDialog: ErrorDialogFragment + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentPersonDetailBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel - return binding.root } @@ -47,42 +51,65 @@ internal class PersonDetailFragment : Fragment() { binding.moviesList.adapter = adapter() binding.showList.adapter = adapter() - viewModel.data.observe(viewLifecycleOwner) { data -> - binding.name.text = data.name - binding.overview.text = data.overview - - setupOverviewExpansion() - - bindItemImage(binding.personImage, data.dto) - } - - viewModel.finishedLoading.observe(viewLifecycleOwner, { - binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE - }) - - viewModel.error.observe(viewLifecycleOwner, { error -> - if (error != null) { - checkIfLoginRequired(error) - binding.errorLayout.errorPanel.visibility = View.VISIBLE - binding.fragmentContent.visibility = View.GONE - } else { - binding.errorLayout.errorPanel.visibility = View.GONE - binding.fragmentContent.visibility = View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is PersonDetailViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is PersonDetailViewModel.UiState.Loading -> bindUiStateLoading() + is PersonDetailViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + viewModel.loadData(args.personId) } - }) + } binding.errorLayout.errorRetryButton.setOnClickListener { viewModel.loadData(args.personId) } binding.errorLayout.errorDetailsButton.setOnClickListener { - ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show( - parentFragmentManager, - "errordialog" - ) + errorDialog.show(parentFragmentManager, "errordialog") + } + } + + private fun bindUiStateNormal(uiState: PersonDetailViewModel.UiState.Normal) { + uiState.apply { + binding.name.text = data.name + binding.overview.text = data.overview + setupOverviewExpansion() + bindItemImage(binding.personImage, data.dto) + + if (starredIn.movies.isNotEmpty()) { + binding.movieLabel.isVisible = true + val moviesAdapter = binding.moviesList.adapter as ViewItemListAdapter + moviesAdapter.submitList(starredIn.movies) + } + if (starredIn.shows.isNotEmpty()) { + binding.showLabel.isVisible = true + val showsAdapter = binding.showList.adapter as ViewItemListAdapter + showsAdapter.submitList(starredIn.shows) + } } - viewModel.loadData(args.personId) + binding.loadingIndicator.isVisible = false + binding.fragmentContent.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: PersonDetailViewModel.UiState.Error) { + val error = uiState.message ?: resources.getString(R.string.unknown_error) + errorDialog = ErrorDialogFragment(error) + binding.loadingIndicator.isVisible = false + binding.fragmentContent.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(error) } private fun adapter() = ViewItemListAdapter( @@ -103,7 +130,6 @@ internal class PersonDetailFragment : Fragment() { binding.overviewGradient.isVisible = false } } - } } diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt index 35e2fc65..d93872b1 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt @@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +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.findNavController import androidx.navigation.fragment.navArgs import dagger.hilt.android.AndroidEntryPoint @@ -17,24 +21,25 @@ import dev.jdtech.jellyfin.databinding.FragmentSearchResultBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber @AndroidEntryPoint class SearchResultFragment : Fragment() { private lateinit var binding: FragmentSearchResultBinding private val viewModel: SearchResultViewModel by viewModels() - private val args: SearchResultFragmentArgs by navArgs() + private lateinit var errorDialog: ErrorDialogFragment + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentSearchResultBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel binding.searchResultsRecyclerView.adapter = FavoritesListAdapter( ViewItemListAdapter.OnClickListener { item -> navigateToMediaInfoFragment(item) @@ -42,42 +47,58 @@ class SearchResultFragment : Fragment() { navigateToEpisodeBottomSheetFragment(item) }) - viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished -> - binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE - }) - - viewModel.error.observe(viewLifecycleOwner, { error -> - if (error != null) { - checkIfLoginRequired(error) - binding.errorLayout.errorPanel.visibility = View.VISIBLE - binding.searchResultsRecyclerView.visibility = View.GONE - } else { - binding.errorLayout.errorPanel.visibility = View.GONE - binding.searchResultsRecyclerView.visibility = View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is SearchResultViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is SearchResultViewModel.UiState.Loading -> bindUiStateLoading() + is SearchResultViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + viewModel.loadData(args.query) } - }) + } binding.errorLayout.errorRetryButton.setOnClickListener { viewModel.loadData(args.query) } binding.errorLayout.errorDetailsButton.setOnClickListener { - ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") + errorDialog.show(parentFragmentManager, "errordialog") } - viewModel.sections.observe(viewLifecycleOwner, { sections -> - if (sections.isEmpty()) { - binding.noSearchResultsText.visibility = View.VISIBLE - } else { - binding.noSearchResultsText.visibility = View.GONE - } - }) - - viewModel.loadData(args.query) return binding.root } + private fun bindUiStateNormal(uiState: SearchResultViewModel.UiState.Normal) { + uiState.apply { + binding.noSearchResultsText.isVisible = sections.isEmpty() + + val adapter = binding.searchResultsRecyclerView.adapter as FavoritesListAdapter + adapter.submitList(uiState.sections) + } + binding.loadingIndicator.isVisible = false + binding.searchResultsRecyclerView.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: SearchResultViewModel.UiState.Error) { + val error = uiState.message ?: getString(R.string.unknown_error) + errorDialog = ErrorDialogFragment(error) + binding.loadingIndicator.isVisible = false + binding.searchResultsRecyclerView.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(error) + } + private fun navigateToMediaInfoFragment(item: BaseItemDto) { findNavController().navigate( FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment( diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt index d680ff90..83b4d45b 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt @@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +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.findNavController import androidx.navigation.fragment.navArgs import dagger.hilt.android.AndroidEntryPoint @@ -15,58 +19,81 @@ import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.SeasonViewModel +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber @AndroidEntryPoint class SeasonFragment : Fragment() { private lateinit var binding: FragmentSeasonBinding private val viewModel: SeasonViewModel by viewModels() - private val args: SeasonFragmentArgs by navArgs() + private lateinit var errorDialog: ErrorDialogFragment + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentSeasonBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.viewModel = viewModel - viewModel.error.observe(viewLifecycleOwner, { error -> - if (error != null) { - checkIfLoginRequired(error) - binding.errorLayout.errorPanel.visibility = View.VISIBLE - binding.episodesRecyclerView.visibility = View.GONE - } else { - binding.errorLayout.errorPanel.visibility = View.GONE - binding.episodesRecyclerView.visibility = View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is SeasonViewModel.UiState.Loading -> bindUiStateLoading() + is SeasonViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + viewModel.loadEpisodes(args.seriesId, args.seasonId) } - }) + } binding.errorLayout.errorRetryButton.setOnClickListener { viewModel.loadEpisodes(args.seriesId, args.seasonId) } binding.errorLayout.errorDetailsButton.setOnClickListener { - ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") + errorDialog.show(parentFragmentManager, "errordialog") } - viewModel.finishedLoading.observe(viewLifecycleOwner, { - binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE - }) - binding.episodesRecyclerView.adapter = EpisodeListAdapter(EpisodeListAdapter.OnClickListener { episode -> navigateToEpisodeBottomSheetFragment(episode) }, args.seriesId, args.seriesName, args.seasonId, args.seasonName) - viewModel.loadEpisodes(args.seriesId, args.seasonId) + } + + private fun bindUiStateNormal(uiState: SeasonViewModel.UiState.Normal) { + uiState.apply { + val adapter = binding.episodesRecyclerView.adapter as EpisodeListAdapter + adapter.submitList(uiState.episodes) + } + binding.loadingIndicator.isVisible = false + binding.episodesRecyclerView.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: SeasonViewModel.UiState.Error) { + val error = uiState.message ?: getString(R.string.unknown_error) + errorDialog = ErrorDialogFragment(error) + binding.loadingIndicator.isVisible = false + binding.episodesRecyclerView.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(error) } private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) { diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerSelectFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerSelectFragment.kt index 886db38a..5ef7f837 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerSelectFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerSelectFragment.kt @@ -6,12 +6,16 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.databinding.FragmentServerSelectBinding import dev.jdtech.jellyfin.dialogs.DeleteServerDialogFragment import dev.jdtech.jellyfin.adapters.ServerGridAdapter import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel +import kotlinx.coroutines.launch @AndroidEntryPoint class ServerSelectFragment : Fragment() { @@ -44,11 +48,15 @@ class ServerSelectFragment : Fragment() { navigateToAddServerFragment() } - viewModel.navigateToMain.observe(viewLifecycleOwner, { - if (it) { - navigateToMainActivity() + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) { + if (it) { + navigateToMainActivity() + } + } } - }) + } return binding.root } @@ -61,6 +69,5 @@ class ServerSelectFragment : Fragment() { private fun navigateToMainActivity() { findNavController().navigate(ServerSelectFragmentDirections.actionServerSelectFragmentToHomeFragment()) - viewModel.doneNavigatingToMain() } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt b/app/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt index 7cedc412..250e7498 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt @@ -5,15 +5,17 @@ import dev.jdtech.jellyfin.models.CollectionType.HomeVideos import dev.jdtech.jellyfin.models.CollectionType.LiveTv import dev.jdtech.jellyfin.models.CollectionType.Music import dev.jdtech.jellyfin.models.CollectionType.Playlists +import dev.jdtech.jellyfin.models.CollectionType.BoxSets enum class CollectionType (val type: String) { HomeVideos("homevideos"), Music("music"), Playlists("playlists"), Books("books"), - LiveTv("livetv") + LiveTv("livetv"), + BoxSets("boxsets") } fun unsupportedCollections() = listOf( - HomeVideos, Music, Playlists, Books, LiveTv + HomeVideos, Music, Playlists, Books, LiveTv, BoxSets ) \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt index 60b4f3fb..2de4a6c1 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt @@ -11,12 +11,17 @@ import androidx.leanback.widget.ArrayObjectAdapter import androidx.leanback.widget.HeaderItem import androidx.leanback.widget.ListRow import androidx.leanback.widget.ListRowPresenter +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.adapters.HomeItem import dev.jdtech.jellyfin.viewmodels.HomeViewModel +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber @AndroidEntryPoint internal class HomeFragment : BrowseSupportFragment() { @@ -48,7 +53,22 @@ internal class HomeFragment : BrowseSupportFragment() { setOnClickListener { navigateToSettingsFragment() } } - viewModel.views().observe(viewLifecycleOwner) { homeItems -> + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is HomeViewModel.UiState.Loading -> Unit + is HomeViewModel.UiState.Error -> Unit + } + } + } + } + } + + private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) { + uiState.apply { rowsAdapter.clear() homeItems.map { section -> rowsAdapter.add(section.toListRow()) } } diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt index f1f01379..f12fee34 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt @@ -11,22 +11,24 @@ import androidx.core.content.ContextCompat 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.R import dev.jdtech.jellyfin.adapters.PersonListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter +import dev.jdtech.jellyfin.bindBaseItemImage import dev.jdtech.jellyfin.databinding.MediaDetailFragmentBinding import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment import dev.jdtech.jellyfin.models.PlayerItem -import dev.jdtech.jellyfin.tv.ui.MediaDetailViewModel.State.Movie -import dev.jdtech.jellyfin.tv.ui.MediaDetailViewModel.State.TvShow import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItemError import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItems +import kotlinx.coroutines.launch import timber.log.Timber @AndroidEntryPoint @@ -35,7 +37,6 @@ internal class MediaDetailFragment : Fragment() { private lateinit var binding: MediaDetailFragmentBinding private val viewModel: MediaInfoViewModel by viewModels() - private val detailViewModel: MediaDetailViewModel by viewModels() private val playerViewModel: PlayerViewModel by viewModels() private val args: MediaDetailFragmentArgs by navArgs() @@ -52,28 +53,29 @@ internal class MediaDetailFragment : Fragment() { savedInstanceState: Bundle? ): View { binding = MediaDetailFragmentBinding.inflate(inflater) - binding.lifecycleOwner = viewLifecycleOwner return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.viewModel = viewModel - binding.item = detailViewModel.transformData(viewModel.item, resources) { - bindActions(it) - bindState(it) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + Timber.d("$uiState") + when (uiState) { + is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading() + is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + } } val seasonsAdapter = ViewItemListAdapter( fixedWidth = true, onClickListener = ViewItemListAdapter.OnClickListener {}) - viewModel.seasons.observe(viewLifecycleOwner) { - seasonsAdapter.submitList(it) - binding.seasonTitle.isVisible = true - } - binding.seasonsRow.gridView.adapter = seasonsAdapter binding.seasonsRow.gridView.verticalSpacing = 25 @@ -81,33 +83,110 @@ internal class MediaDetailFragment : Fragment() { Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show() } - viewModel.actors.observe(viewLifecycleOwner) { cast -> - castAdapter.submitList(cast) - binding.castTitle.isVisible = cast.isNotEmpty() - } - binding.castRow.gridView.adapter = castAdapter binding.castRow.gridView.verticalSpacing = 25 - } - private fun bindState(state: MediaDetailViewModel.State) { - playerViewModel.onPlaybackRequested(lifecycleScope) { state -> - when (state) { - is PlayerItemError -> bindPlayerItemsError(state) - is PlayerItems -> bindPlayerItems(state) + playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> + when (playerItems) { + is PlayerItemError -> bindPlayerItemsError(playerItems) + is PlayerItems -> bindPlayerItems(playerItems) } } - when (state.media) { - is Movie -> binding.title.text = state.media.title - is TvShow -> with(binding.subtitle) { - binding.title.text = state.media.episode - text = state.media.show - isVisible = true + binding.playButton.setOnClickListener { + binding.playButton.setImageResource(android.R.color.transparent) + binding.progressCircular.isVisible = true + viewModel.item?.let { item -> + playerViewModel.loadPlayerItems(item) { + VideoVersionDialogFragment(item, playerViewModel).show( + parentFragmentManager, + "videoversiondialog" + ) + } } } + + binding.trailerButton.setOnClickListener { + if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url) + ) + startActivity(intent) + } + + binding.checkButton.setOnClickListener { + when (viewModel.played) { + true -> { + viewModel.markAsUnplayed(args.itemId) + binding.checkButton.setImageResource(R.drawable.ic_check) + } + false -> { + viewModel.markAsPlayed(args.itemId) + binding.checkButton.setImageResource(R.drawable.ic_check_filled) + } + } + } + + binding.favoriteButton.setOnClickListener { + when (viewModel.favorite) { + true -> { + viewModel.unmarkAsFavorite(args.itemId) + binding.favoriteButton.setImageResource(R.drawable.ic_heart) + } + false -> { + viewModel.markAsFavorite(args.itemId) + binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled) + } + } + } + + binding.backButton.setOnClickListener { activity?.onBackPressed() } } + private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) { + uiState.apply { + binding.seasonTitle.isVisible = seasons.isNotEmpty() + val seasonsAdapter = binding.seasonsRow.gridView.adapter as ViewItemListAdapter + seasonsAdapter.submitList(seasons) + binding.castTitle.isVisible = actors.isNotEmpty() + val actorsAdapter = binding.castRow.gridView.adapter as PersonListAdapter + actorsAdapter.submitList(actors) + + // Check icon + val checkDrawable = when (played) { + true -> R.drawable.ic_check_filled + false -> R.drawable.ic_check + } + binding.checkButton.setImageResource(checkDrawable) + + // Favorite icon + val favoriteDrawable = when (favorite) { + true -> R.drawable.ic_heart_filled + false -> R.drawable.ic_heart + } + binding.favoriteButton.setImageResource(favoriteDrawable) + + binding.title.text = item.name + binding.subtitle.text = item.seriesName + item.seriesName.let { + binding.subtitle.text = it + binding.subtitle.isVisible = true + } + binding.genres.text = genresString + binding.year.text = dateString + binding.playtime.text = runTime + binding.officialRating.text = item.officialRating + binding.communityRating.text = item.communityRating.toString() + binding.description.text = item.overview + bindBaseItemImage(binding.poster, item) + } + } + + private fun bindUiStateLoading() {} + + private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) {} + private fun bindPlayerItems(items: PlayerItems) { navigateToPlayerActivity(items.items.toTypedArray()) binding.playButton.setImageDrawable( @@ -132,59 +211,6 @@ internal class MediaDetailFragment : Fragment() { binding.progressCircular.visibility = View.INVISIBLE } - private fun bindActions(state: MediaDetailViewModel.State) { - binding.playButton.setOnClickListener { - binding.progressCircular.isVisible = true - viewModel.item.value?.let { item -> - playerViewModel.loadPlayerItems(item) { - VideoVersionDialogFragment(item, playerViewModel).show( - parentFragmentManager, - "videoversiondialog" - ) - } - } - } - - if (state.trailerUrl != null) { - with(binding.trailerButton) { - isVisible = true - setOnClickListener { playTrailer(state.trailerUrl) } - } - } else { - binding.trailerButton.isVisible = false - } - - if (state.isPlayed) { - with(binding.checkButton) { - setImageDrawable(resources.getDrawable(R.drawable.ic_check_filled)) - setOnClickListener { viewModel.markAsUnplayed(args.itemId) } - } - } else { - with(binding.checkButton) { - setImageDrawable(resources.getDrawable(R.drawable.ic_check)) - setOnClickListener { viewModel.markAsPlayed(args.itemId) } - } - } - - if (state.isFavorite) { - with(binding.favoriteButton) { - setImageDrawable(resources.getDrawable(R.drawable.ic_heart_filled)) - setOnClickListener { viewModel.unmarkAsFavorite(args.itemId) } - } - } else { - with(binding.favoriteButton) { - setImageDrawable(resources.getDrawable(R.drawable.ic_heart)) - setOnClickListener { viewModel.markAsFavorite(args.itemId) } - } - } - - binding.backButton.setOnClickListener { activity?.onBackPressed() } - } - - private fun playTrailer(url: String) { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - } - private fun navigateToPlayerActivity( playerItems: Array, ) { diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt index d4d46a70..35214149 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt @@ -1,8 +1,6 @@ package dev.jdtech.jellyfin.tv.ui import android.content.res.Resources -import androidx.lifecycle.LiveData -import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.R @@ -14,37 +12,35 @@ import javax.inject.Inject internal class MediaDetailViewModel @Inject internal constructor() : ViewModel() { fun transformData( - data: LiveData, + data: BaseItemDto, resources: Resources, transformed: (State) -> Unit - ): LiveData { - return Transformations.map(data) { baseItemDto -> - State( - dto = baseItemDto, - description = baseItemDto.overview.orEmpty(), - year = baseItemDto.productionYear.toString(), - officialRating = baseItemDto.officialRating.orEmpty(), - communityRating = baseItemDto.communityRating.toString(), + ): State { + return State( + dto = data, + description = data.overview.orEmpty(), + year = data.productionYear.toString(), + officialRating = data.officialRating.orEmpty(), + communityRating = data.communityRating.toString(), runtimeMinutes = String.format( resources.getString(R.string.runtime_minutes), - baseItemDto.runTimeTicks?.div(600_000_000) + data.runTimeTicks?.div(600_000_000) ), - genres = baseItemDto.genres?.joinToString(" / ").orEmpty(), - trailerUrl = baseItemDto.remoteTrailers?.firstOrNull()?.url, - isPlayed = baseItemDto.userData?.played == true, - isFavorite = baseItemDto.userData?.isFavorite == true, - media = if (baseItemDto.type == MOVIE.type) { + genres = data.genres?.joinToString(" / ").orEmpty(), + trailerUrl = data.remoteTrailers?.firstOrNull()?.url, + isPlayed = data.userData?.played == true, + isFavorite = data.userData?.isFavorite == true, + media = if (data.type == MOVIE.type) { State.Movie( - title = baseItemDto.name.orEmpty() + title = data.name.orEmpty() ) } else { State.TvShow( - episode = baseItemDto.episodeTitle ?: baseItemDto.name.orEmpty(), - show = baseItemDto.seriesName.orEmpty() + episode = data.episodeTitle ?: data.name.orEmpty(), + show = data.seriesName.orEmpty() ) } ).also(transformed) - } } data class State( diff --git a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt index 1b591b63..c9934314 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt @@ -5,7 +5,6 @@ import android.content.Context import android.net.Uri import android.os.Environment import androidx.core.content.getSystemService -import androidx.fragment.app.Fragment import dev.jdtech.jellyfin.models.DownloadMetadata import dev.jdtech.jellyfin.models.DownloadRequestItem import dev.jdtech.jellyfin.models.PlayerItem @@ -18,7 +17,7 @@ import java.util.UUID var defaultStorage: File? = null -fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Fragment) { +fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Context) { val downloadRequest = DownloadManager.Request(uri) .setTitle(downloadRequestItem.metadata.name) .setDescription("Downloading") @@ -32,7 +31,7 @@ fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: ) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) if (!File(defaultStorage, downloadRequestItem.itemId.toString()).exists()) - downloadFile(downloadRequest, context.requireContext()) + downloadFile(downloadRequest, context) createMetadataFile( downloadRequestItem.metadata, downloadRequestItem.itemId) diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt index 7f84f090..873deee7 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt @@ -1,68 +1,63 @@ package dev.jdtech.jellyfin.viewmodels -import android.annotation.SuppressLint -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import dev.jdtech.jellyfin.models.DownloadSection import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -import timber.log.Timber import java.util.* class DownloadViewModel : ViewModel() { - private val _downloadSections = MutableLiveData>() - val downloadSections: LiveData> = _downloadSections + private val uiState = MutableStateFlow(UiState.Loading) - private val _finishedLoading = MutableLiveData() - val finishedLoading: LiveData = _finishedLoading + sealed class UiState { + data class Normal(val downloadSections: List) : UiState() + object Loading : UiState() + data class Error(val message: String?) : UiState() + } - private val _error = MutableLiveData() - val error: LiveData = _error + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } init { loadData() } - @SuppressLint("ResourceType") fun loadData() { - _error.value = null - _finishedLoading.value = false viewModelScope.launch { + uiState.emit(UiState.Loading) try { val items = loadDownloadedEpisodes() if (items.isEmpty()) { - _downloadSections.value = listOf() - _finishedLoading.value = true + uiState.emit(UiState.Normal(emptyList())) return@launch } - val tempDownloadSections = mutableListOf() - withContext(Dispatchers.Default) { - DownloadSection( - UUID.randomUUID(), - "Episodes", - items.filter { it.metadata?.type == "Episode"}).let { - if (it.items.isNotEmpty()) tempDownloadSections.add( - it - ) - } + val downloadSections = mutableListOf() + withContext(Dispatchers.Default) { DownloadSection( - UUID.randomUUID(), - "Movies", - items.filter { it.metadata?.type == "Movie" }).let { - if (it.items.isNotEmpty()) tempDownloadSections.add( - it - ) - } + UUID.randomUUID(), + "Episodes", + items.filter { it.metadata?.type == "Episode" }).let { + if (it.items.isNotEmpty()) downloadSections.add( + it + ) } - _downloadSections.value = tempDownloadSections + DownloadSection( + UUID.randomUUID(), + "Movies", + items.filter { it.metadata?.type == "Movie" }).let { + if (it.items.isNotEmpty()) downloadSections.add( + it + ) + } + } + uiState.emit(UiState.Normal(downloadSections)) } catch (e: Exception) { - Timber.e(e) - _error.value = e.toString() + uiState.emit(UiState.Error(e.message)) } - _finishedLoading.value = true } } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt index 3f279421..b686b3c1 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt @@ -1,23 +1,18 @@ package dev.jdtech.jellyfin.viewmodels +import android.app.Application +import android.net.Uri import android.os.Build -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel -import dev.jdtech.jellyfin.models.DownloadMetadata import dev.jdtech.jellyfin.models.DownloadRequestItem import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.repository.JellyfinRepository -import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata -import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode -import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto -import dev.jdtech.jellyfin.utils.itemIsDownloaded +import dev.jdtech.jellyfin.utils.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.LocationType import timber.log.Timber import java.text.DateFormat import java.time.ZoneOffset @@ -29,91 +24,126 @@ import javax.inject.Inject class EpisodeBottomSheetViewModel @Inject constructor( + private val application: Application, private val jellyfinRepository: JellyfinRepository ) : ViewModel() { + private val uiState = MutableStateFlow(UiState.Loading) - private val _item = MutableLiveData() - val item: LiveData = _item + sealed class UiState { + data class Normal( + val episode: BaseItemDto, + val runTime: String, + val dateString: String, + val played: Boolean, + val favorite: Boolean, + val downloaded: Boolean, + val downloadEpisode: Boolean, + ) : UiState() - private val _runTime = MutableLiveData() - val runTime: LiveData = _runTime + object Loading : UiState() + data class Error(val message: String?) : UiState() + } - private val _dateString = MutableLiveData() - val dateString: LiveData = _dateString - - private val _played = MutableLiveData() - val played: LiveData = _played - - private val _favorite = MutableLiveData() - val favorite: LiveData = _favorite - - private val _downloaded = MutableLiveData() - val downloaded: LiveData = _downloaded - - private val _downloadEpisode = MutableLiveData() - val downloadEpisode: LiveData = _downloadEpisode + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } + var item: BaseItemDto? = null + var runTime: String = "" + var dateString: String = "" + var played: Boolean = false + var favorite: Boolean = false + var downloaded: Boolean = false + var downloadEpisode: Boolean = false var playerItems: MutableList = mutableListOf() lateinit var downloadRequestItem: DownloadRequestItem fun loadEpisode(episodeId: UUID) { viewModelScope.launch { + uiState.emit(UiState.Loading) try { - _downloaded.value = itemIsDownloaded(episodeId) - val item = jellyfinRepository.getItem(episodeId) - _item.value = item - _runTime.value = "${item.runTimeTicks?.div(600000000)} min" - _dateString.value = getDateString(item) - _played.value = item.userData?.played - _favorite.value = item.userData?.isFavorite + val tempItem = jellyfinRepository.getItem(episodeId) + item = tempItem + runTime = "${tempItem.runTimeTicks?.div(600000000)} min" + dateString = getDateString(tempItem) + played = tempItem.userData?.played == true + favorite = tempItem.userData?.isFavorite == true + downloaded = itemIsDownloaded(episodeId) + uiState.emit( + UiState.Normal( + tempItem, + runTime, + dateString, + played, + favorite, + downloaded, + downloadEpisode + ) + ) } catch (e: Exception) { - Timber.e(e) + uiState.emit(UiState.Error(e.message)) } } } - fun loadEpisode(playerItem : PlayerItem){ - playerItems.add(playerItem) - _item.value = downloadMetadataToBaseItemDto(playerItem.metadata!!) + fun loadEpisode(playerItem: PlayerItem) { + viewModelScope.launch { + uiState.emit(UiState.Loading) + playerItems.add(playerItem) + item = downloadMetadataToBaseItemDto(playerItem.metadata!!) + uiState.emit( + UiState.Normal( + item!!, + runTime, + dateString, + played, + favorite, + downloaded, + downloadEpisode + ) + ) + } } fun markAsPlayed(itemId: UUID) { viewModelScope.launch { jellyfinRepository.markAsPlayed(itemId) } - _played.value = true + played = true } fun markAsUnplayed(itemId: UUID) { viewModelScope.launch { jellyfinRepository.markAsUnplayed(itemId) } - _played.value = false + played = false } fun markAsFavorite(itemId: UUID) { viewModelScope.launch { jellyfinRepository.markAsFavorite(itemId) } - _favorite.value = true + favorite = true } fun unmarkAsFavorite(itemId: UUID) { viewModelScope.launch { jellyfinRepository.unmarkAsFavorite(itemId) } - _favorite.value = false + favorite = false } fun loadDownloadRequestItem(itemId: UUID) { viewModelScope.launch { - loadEpisode(itemId) - val episode = _item.value + //loadEpisode(itemId) + val episode = item val uri = jellyfinRepository.getStreamUrl(itemId, episode?.mediaSources?.get(0)?.id!!) + Timber.d(uri) val metadata = baseItemDtoToDownloadMetadata(episode) downloadRequestItem = DownloadRequestItem(uri, itemId, metadata) - _downloadEpisode.value = true + downloadEpisode = true + requestDownload(Uri.parse(downloadRequestItem.uri), downloadRequestItem, application) } } @@ -133,7 +163,7 @@ constructor( } fun doneDownloadEpisode() { - _downloadEpisode.value = false - _downloaded.value = true + downloadEpisode = false + downloaded = true } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/FavoriteViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/FavoriteViewModel.kt index ee42e62a..da15f7b3 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/FavoriteViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/FavoriteViewModel.kt @@ -1,16 +1,16 @@ package dev.jdtech.jellyfin.viewmodels -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.models.FavoriteSection import dev.jdtech.jellyfin.repository.JellyfinRepository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import timber.log.Timber import java.util.* import javax.inject.Inject @@ -20,40 +20,41 @@ class FavoriteViewModel constructor( private val jellyfinRepository: JellyfinRepository ) : ViewModel() { - private val _favoriteSections = MutableLiveData>() - val favoriteSections: LiveData> = _favoriteSections + private val uiState = MutableStateFlow(UiState.Loading) - private val _finishedLoading = MutableLiveData() - val finishedLoading: LiveData = _finishedLoading + sealed class UiState { + data class Normal(val favoriteSections: List) : UiState() + object Loading : UiState() + data class Error(val message: String?) : UiState() + } - private val _error = MutableLiveData() - val error: LiveData = _error + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } init { loadData() } fun loadData() { - _error.value = null - _finishedLoading.value = false viewModelScope.launch { + uiState.emit(UiState.Loading) try { val items = jellyfinRepository.getFavoriteItems() if (items.isEmpty()) { - _favoriteSections.value = listOf() - _finishedLoading.value = true + uiState.emit(UiState.Normal(emptyList())) return@launch } - val tempFavoriteSections = mutableListOf() + val favoriteSections = mutableListOf() withContext(Dispatchers.Default) { FavoriteSection( UUID.randomUUID(), "Movies", items.filter { it.type == "Movie" }).let { - if (it.items.isNotEmpty()) tempFavoriteSections.add( + if (it.items.isNotEmpty()) favoriteSections.add( it ) } @@ -61,7 +62,7 @@ constructor( UUID.randomUUID(), "Shows", items.filter { it.type == "Series" }).let { - if (it.items.isNotEmpty()) tempFavoriteSections.add( + if (it.items.isNotEmpty()) favoriteSections.add( it ) } @@ -69,18 +70,16 @@ constructor( UUID.randomUUID(), "Episodes", items.filter { it.type == "Episode" }).let { - if (it.items.isNotEmpty()) tempFavoriteSections.add( + if (it.items.isNotEmpty()) favoriteSections.add( it ) } } - _favoriteSections.value = tempFavoriteSections + uiState.emit(UiState.Normal(favoriteSections)) } catch (e: Exception) { - Timber.e(e) - _error.value = e.toString() + uiState.emit(UiState.Error(e.message)) } - _finishedLoading.value = true } } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt index 2663c4ef..38c47060 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt @@ -2,8 +2,6 @@ package dev.jdtech.jellyfin.viewmodels import android.app.Application import androidx.lifecycle.LifecycleCoroutineScope -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -17,62 +15,49 @@ import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.utils.syncPlaybackProgress import dev.jdtech.jellyfin.utils.toView import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import timber.log.Timber import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject internal constructor( - application: Application, + private val application: Application, private val repository: JellyfinRepository ) : ViewModel() { + private val uiState = MutableStateFlow(UiState.Loading) - private val views = MutableLiveData>() - private val state = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) + sealed class UiState { + data class Normal(val homeItems: List) : UiState() + object Loading : UiState() + data class Error(val message: String?) : UiState() + } + + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } init { loadData(updateCapabilities = true) } - private val continueWatchingString = application.resources.getString(R.string.continue_watching) - private val nextUpString = application.resources.getString(R.string.next_up) - - fun views(): LiveData> = views - - fun onStateUpdate( - scope: LifecycleCoroutineScope, - collector: (State) -> Unit - ) { - scope.launch { state.collect { collector(it) } } - } - fun refreshData() = loadData(updateCapabilities = false) private fun loadData(updateCapabilities: Boolean) { - state.tryEmit(Loading(inProgress = true)) - viewModelScope.launch { + uiState.emit(UiState.Loading) try { if (updateCapabilities) repository.postCapabilities() val updated = loadDynamicItems() + loadViews() - views.postValue(updated) withContext(Dispatchers.Default) { syncPlaybackProgress(repository) } - state.tryEmit(Loading(inProgress = false)) + uiState.emit(UiState.Normal(updated)) } catch (e: Exception) { - Timber.e(e) - state.tryEmit(LoadingError(e.toString())) + uiState.emit(UiState.Error(e.message)) } } } @@ -83,11 +68,11 @@ class HomeViewModel @Inject internal constructor( val items = mutableListOf() if (resumeItems.isNotEmpty()) { - items.add(HomeSection(continueWatchingString, resumeItems)) + items.add(HomeSection(application.resources.getString(R.string.continue_watching), resumeItems)) } if (nextUpItems.isNotEmpty()) { - items.add(HomeSection(nextUpString, nextUpItems)) + items.add(HomeSection(application.resources.getString(R.string.next_up), nextUpItems)) } items.map { Section(it) } @@ -102,11 +87,6 @@ class HomeViewModel @Inject internal constructor( .map { (view, latest) -> view.toView().apply { items = latest } } .map { ViewItem(it) } } - - sealed class State - - data class LoadingError(val message: String) : State() - data class Loading(val inProgress: Boolean) : State() } 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 707e812c..86779196 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/LibraryViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.utils.SortBy +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.SortOrder @@ -14,16 +16,20 @@ import javax.inject.Inject @HiltViewModel class LibraryViewModel @Inject -constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { +constructor( + private val jellyfinRepository: JellyfinRepository +) : ViewModel() { + private val uiState = MutableStateFlow(UiState.Loading) - private val _items = MutableLiveData>() - val items: LiveData> = _items + sealed class UiState { + data class Normal(val items: List) : UiState() + object Loading : UiState() + data class Error(val message: String?) : UiState() + } - private val _finishedLoading = MutableLiveData() - val finishedLoading: LiveData = _finishedLoading - - private val _error = MutableLiveData() - val error: LiveData = _error + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } fun loadItems( parentId: UUID, @@ -31,8 +37,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { sortBy: SortBy = SortBy.defaultValue, sortOrder: SortOrder = SortOrder.ASCENDING ) { - _error.value = null - _finishedLoading.value = false Timber.d("$libraryType") val itemType = when (libraryType) { "movies" -> "Movie" @@ -40,19 +44,19 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { else -> null } viewModelScope.launch { + uiState.emit(UiState.Loading) try { - _items.value = jellyfinRepository.getItems( + val items = jellyfinRepository.getItems( parentId, includeTypes = if (itemType != null) listOf(itemType) else null, recursive = true, sortBy = sortBy, sortOrder = sortOrder ) + uiState.emit(UiState.Normal(items)) } catch (e: Exception) { - Timber.e(e) - _error.value = e.toString() + uiState.emit(UiState.Error(e.message)) } - _finishedLoading.value = true } } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt index 42f70ed8..cdc07dfa 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt @@ -1,8 +1,9 @@ package dev.jdtech.jellyfin.viewmodels +import android.app.Application +import android.net.Uri import android.os.Build -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -13,7 +14,10 @@ import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto import dev.jdtech.jellyfin.utils.itemIsDownloaded +import dev.jdtech.jellyfin.utils.requestDownload import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.BaseItemDto @@ -25,92 +29,134 @@ import javax.inject.Inject @HiltViewModel class MediaInfoViewModel @Inject -constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { +constructor( + private val application: Application, + private val jellyfinRepository: JellyfinRepository + ) : ViewModel() { + private val uiState = MutableStateFlow(UiState.Loading) - private val _item = MutableLiveData() - val item: LiveData = _item + sealed class UiState { + data class Normal( + val item: BaseItemDto, + val actors: List, + val director: BaseItemPerson?, + val writers: List, + val writersString: String, + val genresString: String, + val runTime: String, + val dateString: String, + val nextUp: BaseItemDto?, + val seasons: List, + val played: Boolean, + val favorite: Boolean, + val downloaded: Boolean, + ) : UiState() + object Loading : UiState() + data class Error(val message: String?) : UiState() + } - private val _actors = MutableLiveData>() - val actors: LiveData> = _actors + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } - private val _director = MutableLiveData() - val director: LiveData = _director + var item: BaseItemDto? = null + private var actors: List = emptyList() + private var director: BaseItemPerson? = null + private var writers: List = emptyList() + private var writersString: String = "" + private var genresString: String = "" + private var runTime: String = "" + private var dateString: String = "" + var nextUp: BaseItemDto? = null + var seasons: List = emptyList() + var played: Boolean = false + var favorite: Boolean = false + private var downloaded: Boolean = false + private var downloadMedia: Boolean = false - private val _writers = MutableLiveData>() - val writers: LiveData> = _writers - private val _writersString = MutableLiveData() - val writersString: LiveData = _writersString - - private val _genresString = MutableLiveData() - val genresString: LiveData = _genresString - - private val _runTime = MutableLiveData() - val runTime: LiveData = _runTime - - private val _dateString = MutableLiveData() - val dateString: LiveData = _dateString - - private val _nextUp = MutableLiveData() - val nextUp: LiveData = _nextUp - - private val _seasons = MutableLiveData>() - val seasons: LiveData> = _seasons - - private val _played = MutableLiveData() - val played: LiveData = _played - - private val _favorite = MutableLiveData() - val favorite: LiveData = _favorite - - private val _downloaded = MutableLiveData() - val downloaded: LiveData = _downloaded - - private val _error = MutableLiveData() - val error: LiveData = _error - - private val _downloadMedia = MutableLiveData() - val downloadMedia: LiveData = _downloadMedia - - lateinit var downloadRequestItem: DownloadRequestItem + private lateinit var downloadRequestItem: DownloadRequestItem lateinit var playerItem: PlayerItem fun loadData(itemId: UUID, itemType: String) { - _error.value = null viewModelScope.launch { + uiState.emit(UiState.Loading) try { - _downloaded.value = itemIsDownloaded(itemId) - _item.value = jellyfinRepository.getItem(itemId) - _actors.value = getActors(_item.value!!) - _director.value = getDirector(_item.value!!) - _writers.value = getWriters(_item.value!!) - _writersString.value = - _writers.value?.joinToString(separator = ", ") { it.name.toString() } - _genresString.value = _item.value?.genres?.joinToString(separator = ", ") - _runTime.value = "${_item.value?.runTimeTicks?.div(600000000)} min" - _dateString.value = getDateString(_item.value!!) - _played.value = _item.value?.userData?.played - _favorite.value = _item.value?.userData?.isFavorite - if (itemType == "Series" || itemType == "Episode") { - _nextUp.value = getNextUp(itemId) - _seasons.value = jellyfinRepository.getSeasons(itemId) + val tempItem = jellyfinRepository.getItem(itemId) + item = tempItem + actors = getActors(tempItem) + director = getDirector(tempItem) + writers = getWriters(tempItem) + writersString = writers.joinToString(separator = ", ") { it.name.toString() } + genresString = tempItem.genres?.joinToString(separator = ", ") ?: "" + runTime = "${tempItem.runTimeTicks?.div(600000000)} min" + dateString = getDateString(tempItem) + played = tempItem.userData?.played ?: false + favorite = tempItem.userData?.isFavorite ?: false + downloaded = itemIsDownloaded(itemId) + if (itemType == "Series") { + nextUp = getNextUp(itemId) + seasons = jellyfinRepository.getSeasons(itemId) } + uiState.emit(UiState.Normal( + tempItem, + actors, + director, + writers, + writersString, + genresString, + runTime, + dateString, + nextUp, + seasons, + played, + favorite, + downloaded + )) } catch (e: Exception) { - Timber.e(e) - _error.value = e.toString() + Timber.d(e) + Timber.d(itemId.toString()) + uiState.emit(UiState.Error(e.message)) } } } - fun loadData(playerItem: PlayerItem) { - this.playerItem = playerItem - _item.value = downloadMetadataToBaseItemDto(playerItem.metadata!!) + fun loadData(pItem: PlayerItem) { + viewModelScope.launch { + playerItem = pItem + val tempItem = downloadMetadataToBaseItemDto(playerItem.metadata!!) + item = tempItem + actors = getActors(tempItem) + director = getDirector(tempItem) + writers = getWriters(tempItem) + writersString = writers.joinToString(separator = ", ") { it.name.toString() } + genresString = tempItem.genres?.joinToString(separator = ", ") ?: "" + runTime = "" + dateString = "" + played = tempItem.userData?.played ?: false + favorite = tempItem.userData?.isFavorite ?: false + uiState.emit(UiState.Normal( + tempItem, + actors, + director, + writers, + writersString, + genresString, + runTime, + dateString, + nextUp, + seasons, + played, + favorite, + downloaded + )) + } } - private suspend fun getActors(item: BaseItemDto): List? { - val actors: List? + private suspend fun getActors(item: BaseItemDto): List { + val actors: List withContext(Dispatchers.Default) { - actors = item.people?.filter { it.type == "Actor" } + actors = item.people?.filter { it.type == "Actor" } ?: emptyList() } return actors } @@ -123,10 +169,10 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { return director } - private suspend fun getWriters(item: BaseItemDto): List? { - val writers: List? + private suspend fun getWriters(item: BaseItemDto): List { + val writers: List withContext(Dispatchers.Default) { - writers = item.people?.filter { it.type == "Writer" } + writers = item.people?.filter { it.type == "Writer" } ?: emptyList() } return writers } @@ -144,28 +190,28 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { viewModelScope.launch { jellyfinRepository.markAsPlayed(itemId) } - _played.value = true + played = true } fun markAsUnplayed(itemId: UUID) { viewModelScope.launch { jellyfinRepository.markAsUnplayed(itemId) } - _played.value = false + played = false } fun markAsFavorite(itemId: UUID) { viewModelScope.launch { jellyfinRepository.markAsFavorite(itemId) } - _favorite.value = true + favorite = true } fun unmarkAsFavorite(itemId: UUID) { viewModelScope.launch { jellyfinRepository.unmarkAsFavorite(itemId) } - _favorite.value = false + favorite = false } private fun getDateString(item: BaseItemDto): String { @@ -191,21 +237,17 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { fun loadDownloadRequestItem(itemId: UUID) { viewModelScope.launch { - val downloadItem = _item.value + val downloadItem = item val uri = jellyfinRepository.getStreamUrl(itemId, downloadItem?.mediaSources?.get(0)?.id!!) val metadata = baseItemDtoToDownloadMetadata(downloadItem) downloadRequestItem = DownloadRequestItem(uri, itemId, metadata) - _downloadMedia.value = true + downloadMedia = true + requestDownload(Uri.parse(downloadRequestItem.uri), downloadRequestItem, application) } } fun deleteItem() { deleteDownloadedEpisode(playerItem.mediaSourceUri) } - - fun doneDownloadMedia() { - _downloadMedia.value = false - _downloaded.value = true - } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaViewModel.kt index 359ee45d..6c98b421 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaViewModel.kt @@ -2,10 +2,12 @@ package dev.jdtech.jellyfin.viewmodels import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel +import dev.jdtech.jellyfin.models.unsupportedCollections import dev.jdtech.jellyfin.repository.JellyfinRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto -import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -15,38 +17,35 @@ constructor( private val jellyfinRepository: JellyfinRepository ) : ViewModel() { - private val _collections = MutableLiveData>() - val collections: LiveData> = _collections + private val uiState = MutableStateFlow(UiState.Loading) - private val _finishedLoading = MutableLiveData() - val finishedLoading: LiveData = _finishedLoading + sealed class UiState { + data class Normal(val collections: List) : UiState() + object Loading : UiState() + data class Error(val message: String?) : UiState() + } - private val _error = MutableLiveData() - val error: LiveData = _error + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } init { loadData() } fun loadData() { - _finishedLoading.value = false - _error.value = null viewModelScope.launch { + uiState.emit(UiState.Loading) try { val items = jellyfinRepository.getItems() - _collections.value = - items.filter { - it.collectionType != "homevideos" && - it.collectionType != "music" && - it.collectionType != "playlists" && - it.collectionType != "boxsets" && - it.collectionType != "books" - } + val collections = + items.filter { collection -> unsupportedCollections().none { it.type == collection.collectionType } } + uiState.emit(UiState.Normal(collections)) } catch (e: Exception) { - Timber.e(e) - _error.value = e.toString() + uiState.emit( + UiState.Error(e.message) + ) } - _finishedLoading.value = true } } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PersonDetailViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PersonDetailViewModel.kt index 070b4683..9e919692 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PersonDetailViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PersonDetailViewModel.kt @@ -1,7 +1,6 @@ package dev.jdtech.jellyfin.viewmodels -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -9,9 +8,10 @@ import dev.jdtech.jellyfin.models.ContentType.MOVIE import dev.jdtech.jellyfin.models.ContentType.TVSHOW import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.utils.contentType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto -import timber.log.Timber import java.lang.Exception import java.util.UUID import javax.inject.Inject @@ -21,29 +21,30 @@ internal class PersonDetailViewModel @Inject internal constructor( private val jellyfinRepository: JellyfinRepository ) : ViewModel() { - val data = MutableLiveData() - val starredIn = MutableLiveData() + private val uiState = MutableStateFlow(UiState.Loading) - private val _finishedLoading = MutableLiveData() - val finishedLoading: LiveData = _finishedLoading + sealed class UiState { + data class Normal(val data: PersonOverview, val starredIn: StarredIn) : UiState() + object Loading : UiState() + data class Error(val message: String?) : UiState() + } - private val _error = MutableLiveData() - val error: LiveData = _error + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } fun loadData(personId: UUID) { - _error.value = null - _finishedLoading.value = false viewModelScope.launch { + uiState.emit(UiState.Loading) try { val personDetail = jellyfinRepository.getItem(personId) - data.postValue( - PersonOverview( - name = personDetail.name.orEmpty(), - overview = personDetail.overview.orEmpty(), - dto = personDetail - ) + val data = PersonOverview( + name = personDetail.name.orEmpty(), + overview = personDetail.overview.orEmpty(), + dto = personDetail ) + val items = jellyfinRepository.getPersonItems( personIds = listOf(personId), includeTypes = listOf(MOVIE, TVSHOW), @@ -53,13 +54,12 @@ internal class PersonDetailViewModel @Inject internal constructor( val movies = items.filter { it.contentType() == MOVIE } val shows = items.filter { it.contentType() == TVSHOW } - starredIn.postValue(StarredIn(movies, shows)) + val starredIn = StarredIn(movies, shows) + uiState.emit(UiState.Normal(data, starredIn)) } catch (e: Exception) { - Timber.e(e) - _error.value = e.toString() + uiState.emit(UiState.Error(e.message)) } - _finishedLoading.value = true } } diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/SearchResultViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/SearchResultViewModel.kt index 3a656efd..f9484e01 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/SearchResultViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/SearchResultViewModel.kt @@ -1,16 +1,16 @@ package dev.jdtech.jellyfin.viewmodels -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.models.FavoriteSection import dev.jdtech.jellyfin.repository.JellyfinRepository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import timber.log.Timber import java.util.* import javax.inject.Inject @@ -20,36 +20,37 @@ class SearchResultViewModel constructor( private val jellyfinRepository: JellyfinRepository ) : ViewModel() { - private val _sections = MutableLiveData>() - val sections: LiveData> = _sections + private val uiState = MutableStateFlow(UiState.Loading) - private val _finishedLoading = MutableLiveData() - val finishedLoading: LiveData = _finishedLoading + sealed class UiState { + data class Normal(val sections: List) : UiState() + object Loading : UiState() + data class Error(val message: String?) : UiState() + } - private val _error = MutableLiveData() - val error: LiveData = _error + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } fun loadData(query: String) { - _error.value = null - _finishedLoading.value = false viewModelScope.launch { + uiState.emit(UiState.Loading) try { val items = jellyfinRepository.getSearchItems(query) if (items.isEmpty()) { - _sections.value = listOf() - _finishedLoading.value = true + uiState.emit(UiState.Normal(emptyList())) return@launch } - val tempSections = mutableListOf() + val sections = mutableListOf() withContext(Dispatchers.Default) { FavoriteSection( UUID.randomUUID(), "Movies", items.filter { it.type == "Movie" }).let { - if (it.items.isNotEmpty()) tempSections.add( + if (it.items.isNotEmpty()) sections.add( it ) } @@ -57,7 +58,7 @@ constructor( UUID.randomUUID(), "Shows", items.filter { it.type == "Series" }).let { - if (it.items.isNotEmpty()) tempSections.add( + if (it.items.isNotEmpty()) sections.add( it ) } @@ -65,18 +66,16 @@ constructor( UUID.randomUUID(), "Episodes", items.filter { it.type == "Episode" }).let { - if (it.items.isNotEmpty()) tempSections.add( + if (it.items.isNotEmpty()) sections.add( it ) } } - _sections.value = tempSections + uiState.emit(UiState.Normal(sections)) } catch (e: Exception) { - Timber.e(e) - _error.value = e.toString() + uiState.emit(UiState.Error(e.message)) } - _finishedLoading.value = true } } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/SeasonViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/SeasonViewModel.kt index 2be9065c..4ce03074 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/SeasonViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/SeasonViewModel.kt @@ -1,48 +1,49 @@ package dev.jdtech.jellyfin.viewmodels -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.adapters.EpisodeItem import dev.jdtech.jellyfin.repository.JellyfinRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.ItemFields -import timber.log.Timber import java.util.* import javax.inject.Inject @HiltViewModel class SeasonViewModel @Inject -constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { +constructor( + private val jellyfinRepository: JellyfinRepository +) : ViewModel() { + private val uiState = MutableStateFlow(UiState.Loading) - private val _episodes = MutableLiveData>() - val episodes: LiveData> = _episodes + sealed class UiState { + data class Normal(val episodes: List) : UiState() + object Loading : UiState() + data class Error(val message: String?) : UiState() + } - private val _finishedLoading = MutableLiveData() - val finishedLoading: LiveData = _finishedLoading - - private val _error = MutableLiveData() - val error: LiveData = _error + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } fun loadEpisodes(seriesId: UUID, seasonId: UUID) { - _error.value = null - _finishedLoading.value = false viewModelScope.launch { + uiState.emit(UiState.Loading) try { - _episodes.value = getEpisodes(seriesId, seasonId) + val episodes = getEpisodes(seriesId, seasonId) + uiState.emit(UiState.Normal(episodes)) } catch (e: Exception) { - Timber.e(e) - _error.value = e.toString() + uiState.emit(UiState.Error(e.message)) } - _finishedLoading.value = true } } private suspend fun getEpisodes(seriesId: UUID, seasonId: UUID): List { - val episodes = jellyfinRepository.getEpisodes(seriesId, seasonId, fields = listOf(ItemFields.OVERVIEW)) + val episodes = + jellyfinRepository.getEpisodes(seriesId, seasonId, fields = listOf(ItemFields.OVERVIEW)) return listOf(EpisodeItem.Header) + episodes.map { EpisodeItem.Episode(it) } } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/ServerSelectViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/ServerSelectViewModel.kt index c459f477..6721b732 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/ServerSelectViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/ServerSelectViewModel.kt @@ -1,8 +1,7 @@ package dev.jdtech.jellyfin.viewmodels import android.content.SharedPreferences -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -10,6 +9,9 @@ import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.database.Server import dev.jdtech.jellyfin.database.ServerDatabaseDao import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.* @@ -23,12 +25,17 @@ constructor( private val jellyfinApi: JellyfinApi, private val database: ServerDatabaseDao, ) : ViewModel() { + val servers = database.getAllServers() - private val _servers = database.getAllServers() - val servers: LiveData> = _servers + private val navigateToMain = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) - private val _navigateToMain = MutableLiveData() - val navigateToMain: LiveData = _navigateToMain + fun onNavigateToMain(scope: LifecycleCoroutineScope, collector: (Boolean) -> Unit) { + scope.launch { navigateToMain.collect { collector(it) } } + } /** * Delete server from database @@ -54,10 +61,6 @@ constructor( userId = UUID.fromString(server.userId) } - _navigateToMain.value = true - } - - fun doneNavigatingToMain() { - _navigateToMain.value = false + navigateToMain.tryEmit(true) } } \ No newline at end of file diff --git a/app/src/main/res/layout-television/media_detail_fragment.xml b/app/src/main/res/layout-television/media_detail_fragment.xml index eb89c444..57ec35e0 100644 --- a/app/src/main/res/layout-television/media_detail_fragment.xml +++ b/app/src/main/res/layout-television/media_detail_fragment.xml @@ -1,297 +1,250 @@ - + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:ignore="MissingDefaultResource"> - + - - - - - - - - + android:layout_height="wrap_content"> - - - + android:layout_height="wrap_content"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/season_title" /> - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/episode_bottom_sheet.xml b/app/src/main/res/layout/episode_bottom_sheet.xml index 6537b1dd..85345e22 100644 --- a/app/src/main/res/layout/episode_bottom_sheet.xml +++ b/app/src/main/res/layout/episode_bottom_sheet.xml @@ -1,259 +1,265 @@ - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingBottom="24dp"> - + - + - + - - - - - - - - - - - - - - - + + + + + + + + + + + + android:layout_marginEnd="8dp" + android:textAppearance="@style/TextAppearance.AppCompat.Body1" + tools:text="4/6/2013" /> - + android:layout_marginEnd="8dp" + android:textAppearance="@style/TextAppearance.AppCompat.Body1" + tools:text="26 min" /> - - - - - - - - - - + android:drawablePadding="4dp" + android:gravity="bottom" + android:textAppearance="@style/TextAppearance.AppCompat.Body1" + app:drawableStartCompat="@drawable/ic_star" + app:drawableTint="@color/yellow" + tools:text="8.8" /> - + - + - - + + android:id="@+id/play_button" + android:layout_width="72dp" + android:layout_height="48dp" + android:background="@drawable/button_setup_background" + android:contentDescription="@string/play_button_description" + android:foreground="@drawable/ripple_background" + android:paddingHorizontal="24dp" + android:paddingVertical="12dp" + android:src="@drawable/ic_play" /> - + + + + + + + + - - + + - + android:layout_marginEnd="12dp" + android:background="@drawable/button_accent_background" + android:contentDescription="@string/delete_button_description" + android:padding="12dp" + android:src="@drawable/ic_trash" /> + - - - - + + android:textColor="?attr/colorError" /> - + + - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_download.xml b/app/src/main/res/layout/fragment_download.xml index f22905c8..f1c89120 100644 --- a/app/src/main/res/layout/fragment_download.xml +++ b/app/src/main/res/layout/fragment_download.xml @@ -1,61 +1,50 @@ - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent"> - - - + - + - + - + - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_favorite.xml b/app/src/main/res/layout/fragment_favorite.xml index fadcd7cd..b80181bb 100644 --- a/app/src/main/res/layout/fragment_favorite.xml +++ b/app/src/main/res/layout/fragment_favorite.xml @@ -1,61 +1,48 @@ - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent"> - - - + - + - + - + - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 9abb4f55..c07b29ac 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -1,63 +1,44 @@ - + android:id="@+id/refresh_layout" + android:layout_width="match_parent" + android:layout_height="match_parent"> - - - - - - + tools:context=".fragments.HomeFragment"> - + - + - + + - - - - - - + diff --git a/app/src/main/res/layout/fragment_library.xml b/app/src/main/res/layout/fragment_library.xml index 51042b48..91b25ade 100644 --- a/app/src/main/res/layout/fragment_library.xml +++ b/app/src/main/res/layout/fragment_library.xml @@ -1,52 +1,42 @@ - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".fragments.LibraryFragment"> - + - - + - + - - - - - - - - - + diff --git a/app/src/main/res/layout/fragment_media.xml b/app/src/main/res/layout/fragment_media.xml index ae44afcc..8eb4df35 100644 --- a/app/src/main/res/layout/fragment_media.xml +++ b/app/src/main/res/layout/fragment_media.xml @@ -1,51 +1,42 @@ - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:animateLayoutChanges="true" + tools:context=".fragments.MediaFragment"> - + - - + - + - - - - - - - - + diff --git a/app/src/main/res/layout/fragment_media_info.xml b/app/src/main/res/layout/fragment_media_info.xml index 81625285..3f2ebb5b 100644 --- a/app/src/main/res/layout/fragment_media_info.xml +++ b/app/src/main/res/layout/fragment_media_info.xml @@ -1,469 +1,439 @@ - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent"> - + - + - - + - + - + - + + + + + + + android:layout_marginHorizontal="24dp" + android:layout_marginBottom="16dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:layout_height="wrap_content" + android:layout_marginBottom="12dp"> - - - - - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="Action, Science Fiction, Adventure" /> - - + android:layout_marginBottom="12dp"> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="Robert Rodriguez" /> + + + + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="James Cameron, Laeta Kalogridis, Yukito Kishiro" /> + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_person_detail.xml b/app/src/main/res/layout/fragment_person_detail.xml index 346d5bb8..cf01d016 100644 --- a/app/src/main/res/layout/fragment_person_detail.xml +++ b/app/src/main/res/layout/fragment_person_detail.xml @@ -1,169 +1,153 @@ - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent"> - + - + - - + - - - + android:orientation="vertical"> - + - + + + + + + + + +