From c1740c1b6879e329430a34ef28c35c6eddf18dc5 Mon Sep 17 00:00:00 2001 From: Jcuhfehl <91626737+Jcuhfehl@users.noreply.github.com> Date: Sat, 11 Jun 2022 13:35:52 +0200 Subject: [PATCH] Display downloaded episodes by series (#80) * Display downloaded episodes by series * Add offline playback to readme * Remove accidentally commited changes * Remove duplicate movie section in downloadviewmodel * Fix issues with merging upstream * Notify on download completion * Fix trash icon color * Update DownloadSeriesFragment to use new UiState system * Clean up unused code Co-authored-by: Jarne Demeulemeester --- README.md | 1 + .../adapters/DownloadEpisodeListAdapter.kt | 129 +++++++++++++----- .../adapters/DownloadSeriesListAdapter.kt | 65 +++++++++ .../adapters/DownloadViewItemListAdapter.kt | 6 +- .../jellyfin/adapters/DownloadsListAdapter.kt | 23 ++-- .../jellyfin/database/DownloadDatabaseDao.kt | 1 - .../jellyfin/fragments/DownloadFragment.kt | 17 +-- .../fragments/DownloadSeriesFragment.kt | 99 ++++++++++++++ .../jdtech/jellyfin/models/DownloadSection.kt | 3 +- .../jellyfin/models/DownloadSeriesMetadata.kt | 12 ++ .../jellyfin/utils/DownloadUtilities.kt | 19 +++ .../viewmodels/DownloadSeriesViewModel.kt | 46 +++++++ .../jellyfin/viewmodels/DownloadViewModel.kt | 24 +++- .../main/res/layout/episode_bottom_sheet.xml | 4 +- .../res/layout/fragment_download_series.xml | 36 +++++ .../main/res/navigation/app_navigation.xml | 20 +++ 16 files changed, 438 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadSeriesListAdapter.kt create mode 100644 app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadSeriesFragment.kt create mode 100644 app/src/main/java/dev/jdtech/jellyfin/models/DownloadSeriesMetadata.kt create mode 100644 app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadSeriesViewModel.kt create mode 100644 app/src/main/res/layout/fragment_download_series.xml diff --git a/README.md b/README.md index 5e12d776..4d3bc8d6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Home | Library | Movie | Season | Episode - Completely native interface - Supported media items: movies, series, seasons, episodes - Direct play only, (no transcoding) +- Offline playback / downloads - ExoPlayer - Video codecs: H.263, H.264, H.265, VP8, VP9, AV1 - Support depends on Android device diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt index f16e9c1b..70555829 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt @@ -7,63 +7,122 @@ import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding -import dev.jdtech.jellyfin.models.ContentType +import dev.jdtech.jellyfin.databinding.EpisodeItemBinding +import dev.jdtech.jellyfin.databinding.SeasonHeaderBinding +import dev.jdtech.jellyfin.models.DownloadSeriesMetadata import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto -import timber.log.Timber +import org.jellyfin.sdk.model.api.BaseItemDto +import java.util.UUID -class DownloadEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter(DiffCallback) { - class EpisodeViewHolder(private var binding: HomeEpisodeItemBinding) : +private const val ITEM_VIEW_TYPE_HEADER = 0 +private const val ITEM_VIEW_TYPE_EPISODE = 1 + +class DownloadEpisodeListAdapter( + private val onClickListener: OnClickListener, + private val downloadSeriesMetadata: DownloadSeriesMetadata +) : + ListAdapter(DiffCallback) { + + class HeaderViewHolder(private var binding: SeasonHeaderBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(episode: PlayerItem) { - val metadata = episode.item!! - binding.episode = downloadMetadataToBaseItemDto(episode.item) - if (metadata.playedPercentage != null) { - binding.progressBar.layoutParams.width = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, - (metadata.playedPercentage.times(2.24)).toFloat(), binding.progressBar.context.resources.displayMetrics).toInt() + fun bind( + metadata: DownloadSeriesMetadata + ) { + binding.seasonName.text = metadata.name + binding.seriesId = metadata.itemId + binding.seasonId = metadata.itemId + binding.executePendingBindings() + } + } + + class EpisodeViewHolder(private var binding: EpisodeItemBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(episode: BaseItemDto) { + binding.episode = episode + if (episode.userData?.playedPercentage != null) { + binding.progressBar.layoutParams.width = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + (episode.userData?.playedPercentage?.times(.84))!!.toFloat(), + binding.progressBar.context.resources.displayMetrics + ).toInt() binding.progressBar.visibility = View.VISIBLE - } - if (metadata.type == ContentType.MOVIE) { - binding.primaryName.text = metadata.name - Timber.d(metadata.name) - binding.secondaryName.visibility = View.GONE - } else if (metadata.type == ContentType.EPISODE) { - binding.primaryName.text = metadata.seriesName + } else { + binding.progressBar.visibility = View.GONE } binding.executePendingBindings() } } - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean { - return oldItem.itemId == newItem.itemId + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DownloadEpisodeItem, newItem: DownloadEpisodeItem): Boolean { + return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean { + override fun areContentsTheSame(oldItem: DownloadEpisodeItem, newItem: DownloadEpisodeItem): Boolean { return oldItem == newItem } } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder { - return EpisodeViewHolder( - HomeEpisodeItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + ITEM_VIEW_TYPE_HEADER -> { + HeaderViewHolder( + SeasonHeaderBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + ITEM_VIEW_TYPE_EPISODE -> { + EpisodeViewHolder( + EpisodeItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + else -> throw ClassCastException("Unknown viewType $viewType") + } } - override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) { - val item = getItem(position) - holder.itemView.setOnClickListener { - onClickListener.onClick(item) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder.itemViewType) { + ITEM_VIEW_TYPE_HEADER -> { + (holder as HeaderViewHolder).bind(downloadSeriesMetadata) + } + ITEM_VIEW_TYPE_EPISODE -> { + val item = getItem(position) as DownloadEpisodeItem.Episode + holder.itemView.setOnClickListener { + onClickListener.onClick(item.episode) + } + (holder as EpisodeViewHolder).bind(downloadMetadataToBaseItemDto(item.episode.item!!)) + } + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is DownloadEpisodeItem.Header -> ITEM_VIEW_TYPE_HEADER + is DownloadEpisodeItem.Episode -> ITEM_VIEW_TYPE_EPISODE } - holder.bind(item) } class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) { fun onClick(item: PlayerItem) = clickListener(item) } +} + +sealed class DownloadEpisodeItem { + abstract val id: UUID + + object Header : DownloadEpisodeItem() { + override val id: UUID = UUID.randomUUID() + } + + data class Episode(val episode: PlayerItem) : DownloadEpisodeItem() { + override val id = episode.itemId + } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadSeriesListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadSeriesListAdapter.kt new file mode 100644 index 00000000..4494b51f --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadSeriesListAdapter.kt @@ -0,0 +1,65 @@ +package dev.jdtech.jellyfin.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import dev.jdtech.jellyfin.R +import dev.jdtech.jellyfin.databinding.BaseItemBinding +import dev.jdtech.jellyfin.models.DownloadSeriesMetadata +import dev.jdtech.jellyfin.utils.downloadSeriesMetadataToBaseItemDto + +class DownloadSeriesListAdapter( + private val onClickListener: OnClickListener, + private val fixedWidth: Boolean = false, + ) : + ListAdapter(DiffCallback) { + + class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) : + RecyclerView.ViewHolder(binding.root) { + fun bind(item: DownloadSeriesMetadata, fixedWidth: Boolean) { + binding.item = downloadSeriesMetadataToBaseItemDto(item) + binding.itemName.text = item.name + binding.itemCount.text = item.episodes.size.toString() + if (fixedWidth) { + binding.itemLayout.layoutParams.width = + parent.resources.getDimension(R.dimen.overview_media_width).toInt() + (binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0 + } + binding.executePendingBindings() + } + } + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DownloadSeriesMetadata, newItem: DownloadSeriesMetadata): Boolean { + return oldItem.itemId == newItem.itemId + } + + override fun areContentsTheSame(oldItem: DownloadSeriesMetadata, newItem: DownloadSeriesMetadata): Boolean { + return oldItem == newItem + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { + return ItemViewHolder( + BaseItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), parent + ) + } + + override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { + val item = getItem(position) + holder.itemView.setOnClickListener { + onClickListener.onClick(item) + } + holder.bind(item, fixedWidth) + } + + class OnClickListener(val clickListener: (item: DownloadSeriesMetadata) -> Unit) { + fun onClick(item: DownloadSeriesMetadata) = clickListener(item) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt index 32eed6c4..050b9065 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt @@ -8,7 +8,6 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.databinding.BaseItemBinding -import dev.jdtech.jellyfin.models.ContentType import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto @@ -23,11 +22,10 @@ class DownloadViewItemListAdapter( fun bind(item: PlayerItem, fixedWidth: Boolean) { val metadata = item.item!! binding.item = downloadMetadataToBaseItemDto(metadata) - binding.itemName.text = if (metadata.type == ContentType.EPISODE) metadata.seriesName else item.name + binding.itemName.text = item.name binding.itemCount.visibility = View.GONE if (fixedWidth) { - binding.itemLayout.layoutParams.width = - parent.resources.getDimension(R.dimen.overview_media_width).toInt() + binding.itemLayout.layoutParams.width = parent.resources.getDimension(R.dimen.overview_media_width).toInt() (binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0 } binding.executePendingBindings() diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt index 9a0866b9..cbeb2521 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt @@ -10,24 +10,25 @@ import dev.jdtech.jellyfin.models.DownloadSection class DownloadsListAdapter( private val onClickListener: DownloadViewItemListAdapter.OnClickListener, - private val onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener + private val onSeriesClickListener: DownloadSeriesListAdapter.OnClickListener ) : ListAdapter(DiffCallback) { class SectionViewHolder(private var binding: DownloadSectionBinding) : RecyclerView.ViewHolder(binding.root) { fun bind( section: DownloadSection, onClickListener: DownloadViewItemListAdapter.OnClickListener, - onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener + onSeriesClickListener: DownloadSeriesListAdapter.OnClickListener ) { binding.section = section - if (section.name == "Movies" || section.name == "Shows") { - binding.itemsRecyclerView.adapter = - DownloadViewItemListAdapter(onClickListener, fixedWidth = true) - (binding.itemsRecyclerView.adapter as DownloadViewItemListAdapter).submitList(section.items) - } else if (section.name == "Episodes") { - binding.itemsRecyclerView.adapter = - DownloadEpisodeListAdapter(onEpisodeClickListener) - (binding.itemsRecyclerView.adapter as DownloadEpisodeListAdapter).submitList(section.items) + when (section.name) { + "Movies" -> { + binding.itemsRecyclerView.adapter = DownloadViewItemListAdapter(onClickListener, fixedWidth = true) + (binding.itemsRecyclerView.adapter as DownloadViewItemListAdapter).submitList(section.items) + } + "Shows" -> { + binding.itemsRecyclerView.adapter = DownloadSeriesListAdapter(onSeriesClickListener, fixedWidth = true) + (binding.itemsRecyclerView.adapter as DownloadSeriesListAdapter).submitList(section.series) + } } binding.executePendingBindings() } @@ -58,6 +59,6 @@ class DownloadsListAdapter( override fun onBindViewHolder(holder: SectionViewHolder, position: Int) { val collection = getItem(position) - holder.bind(collection, onClickListener, onEpisodeClickListener) + holder.bind(collection, onClickListener, onSeriesClickListener) } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabaseDao.kt b/app/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabaseDao.kt index c93191e3..1fe8e989 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabaseDao.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabaseDao.kt @@ -2,7 +2,6 @@ package dev.jdtech.jellyfin.database import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query import dev.jdtech.jellyfin.models.DownloadItem import java.util.* 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 a07d1a2b..df8b7c8d 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt @@ -15,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.adapters.* import dev.jdtech.jellyfin.databinding.FragmentDownloadBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.DownloadSeriesMetadata import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.DownloadViewModel @@ -39,9 +40,10 @@ class DownloadFragment : Fragment() { binding.downloadsRecyclerView.adapter = DownloadsListAdapter( DownloadViewItemListAdapter.OnClickListener { item -> navigateToMediaInfoFragment(item) - }, DownloadEpisodeListAdapter.OnClickListener { item -> - navigateToEpisodeBottomSheetFragment(item) - }) + }, DownloadSeriesListAdapter.OnClickListener { item -> + navigateToDownloadSeriesFragment(item) + } + ) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -104,12 +106,11 @@ class DownloadFragment : Fragment() { ) } - private fun navigateToEpisodeBottomSheetFragment(episode: PlayerItem) { + private fun navigateToDownloadSeriesFragment(series: DownloadSeriesMetadata) { findNavController().navigate( - DownloadFragmentDirections.actionDownloadFragmentToEpisodeBottomSheetFragment( - UUID.randomUUID(), - episode, - isOffline = true + DownloadFragmentDirections.actionDownloadFragmentToDownloadSeriesFragment( + seriesMetadata = series, + seriesName = series.name ) ) } diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadSeriesFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadSeriesFragment.kt new file mode 100644 index 00000000..3adf6d77 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadSeriesFragment.kt @@ -0,0 +1,99 @@ +package dev.jdtech.jellyfin.fragments + +import android.os.Bundle +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 +import dev.jdtech.jellyfin.adapters.DownloadEpisodeListAdapter +import dev.jdtech.jellyfin.databinding.FragmentDownloadSeriesBinding +import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.PlayerItem +import dev.jdtech.jellyfin.viewmodels.DownloadSeriesViewModel +import kotlinx.coroutines.launch +import java.util.* + +@AndroidEntryPoint +class DownloadSeriesFragment : Fragment() { + + private lateinit var binding: FragmentDownloadSeriesBinding + private val viewModel: DownloadSeriesViewModel by viewModels() + + private lateinit var errorDialog: ErrorDialogFragment + + private val args: DownloadSeriesFragmentArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentDownloadSeriesBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.viewModel = viewModel + + binding.episodesRecyclerView.adapter = + DownloadEpisodeListAdapter(DownloadEpisodeListAdapter.OnClickListener { episode -> + navigateToEpisodeBottomSheetFragment(episode) + }, args.seriesMetadata) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + when (uiState) { + is DownloadSeriesViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is DownloadSeriesViewModel.UiState.Loading -> bindUiStateLoading(uiState) + is DownloadSeriesViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + } + } + + binding.errorLayout.errorRetryButton.setOnClickListener { + viewModel.loadEpisodes(args.seriesMetadata) + } + + binding.errorLayout.errorDetailsButton.setOnClickListener { + errorDialog.show(parentFragmentManager, "errordialog") + } + + viewModel.loadEpisodes(args.seriesMetadata) + } + + private fun bindUiStateNormal(uiState: DownloadSeriesViewModel.UiState.Normal) { + val adapter = binding.episodesRecyclerView.adapter as DownloadEpisodeListAdapter + adapter.submitList(uiState.downloadEpisodes) + binding.episodesRecyclerView.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateLoading(uiState: DownloadSeriesViewModel.UiState.Loading) {} + + private fun bindUiStateError(uiState: DownloadSeriesViewModel.UiState.Error) { + errorDialog = ErrorDialogFragment(uiState.error) + binding.episodesRecyclerView.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + } + + private fun navigateToEpisodeBottomSheetFragment(episode: PlayerItem) { + findNavController().navigate( + DownloadSeriesFragmentDirections.actionDownloadSeriesFragmentToEpisodeBottomSheetFragment( + UUID.randomUUID(), + episode, + isOffline = true + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSection.kt b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSection.kt index 232ca9e6..74862086 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSection.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSection.kt @@ -5,5 +5,6 @@ import java.util.* data class DownloadSection( val id: UUID, val name: String, - var items: List + val items: List? = null, + val series: List? = null ) \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSeriesMetadata.kt b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSeriesMetadata.kt new file mode 100644 index 00000000..1343848d --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSeriesMetadata.kt @@ -0,0 +1,12 @@ +package dev.jdtech.jellyfin.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.* + +@Parcelize +data class DownloadSeriesMetadata( + val itemId: UUID, + val name: String? = null, + val episodes: List +) : Parcelable \ No newline at end of file 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 11c1bd5f..cdaa2f30 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt @@ -9,6 +9,7 @@ import androidx.preference.PreferenceManager import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.models.DownloadItem import dev.jdtech.jellyfin.models.DownloadRequestItem +import dev.jdtech.jellyfin.models.DownloadSeriesMetadata import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.repository.JellyfinRepository import org.jellyfin.sdk.model.api.BaseItemDto @@ -175,6 +176,24 @@ fun baseItemDtoToDownloadMetadata(item: BaseItemDto): DownloadItem { ) } +fun downloadSeriesMetadataToBaseItemDto(metadata: DownloadSeriesMetadata): BaseItemDto { + val userData = UserItemDataDto( + playbackPositionTicks = 0, + playedPercentage = 0.0, + isFavorite = false, + playCount = 0, + played = false, + unplayedItemCount = metadata.episodes.size + ) + + return BaseItemDto( + id = metadata.itemId, + type = "Series", + name = metadata.name, + userData = userData + ) +} + suspend fun syncPlaybackProgress( downloadDatabase: DownloadDatabaseDao, jellyfinRepository: JellyfinRepository diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadSeriesViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadSeriesViewModel.kt new file mode 100644 index 00000000..970fb3ad --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadSeriesViewModel.kt @@ -0,0 +1,46 @@ +package dev.jdtech.jellyfin.viewmodels + +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.jdtech.jellyfin.adapters.DownloadEpisodeItem +import dev.jdtech.jellyfin.models.DownloadSeriesMetadata +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DownloadSeriesViewModel +@Inject +constructor() : ViewModel() { + private val uiState = MutableStateFlow(UiState.Loading) + + sealed class UiState { + data class Normal(val downloadEpisodes: List) : UiState() + object Loading : UiState() + data class Error(val error: Exception) : UiState() + } + + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } + + fun loadEpisodes(seriesMetadata: DownloadSeriesMetadata) { + viewModelScope.launch { + uiState.emit(UiState.Loading) + try { + uiState.emit(UiState.Normal(getEpisodes((seriesMetadata)))) + } catch (e: Exception) { + uiState.emit(UiState.Error(e)) + } + } + } + + private fun getEpisodes(seriesMetadata: DownloadSeriesMetadata): List { + val episodes = seriesMetadata.episodes + return listOf(DownloadEpisodeItem.Header) + episodes.sortedWith(compareBy( + { it.item!!.parentIndexNumber }, + { it.item!!.indexNumber })).map { DownloadEpisodeItem.Episode(it) } + } +} \ No newline at end of file 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 6981e19e..b60e8c7c 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt @@ -5,6 +5,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.models.ContentType import dev.jdtech.jellyfin.models.DownloadSection +import dev.jdtech.jellyfin.models.DownloadSeriesMetadata +import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow @@ -39,21 +41,31 @@ constructor( uiState.emit(UiState.Loading) try { val items = loadDownloadedEpisodes(downloadDatabase) + + val showsMap = mutableMapOf>() + items.filter { it.item?.type == ContentType.EPISODE }.forEach { + showsMap.computeIfAbsent(it.item!!.seriesId!!) { mutableListOf() } += it + } + val shows = showsMap.map { DownloadSeriesMetadata(it.key, it.value[0].item!!.seriesName, it.value) } + val downloadSections = mutableListOf() withContext(Dispatchers.Default) { DownloadSection( UUID.randomUUID(), - "Episodes", - items.filter { it.item?.type == ContentType.EPISODE }).let { - if (it.items.isNotEmpty()) downloadSections.add( + "Movies", + items.filter { it.item?.type == ContentType.MOVIE } + ).let { + if (it.items!!.isNotEmpty()) downloadSections.add( it ) } DownloadSection( UUID.randomUUID(), - "Movies", - items.filter { it.item?.type == ContentType.MOVIE }).let { - if (it.items.isNotEmpty()) downloadSections.add( + "Shows", + null, + shows + ).let { + if (it.series!!.isNotEmpty()) downloadSections.add( it ) } diff --git a/app/src/main/res/layout/episode_bottom_sheet.xml b/app/src/main/res/layout/episode_bottom_sheet.xml index 017d1628..0b0a288e 100644 --- a/app/src/main/res/layout/episode_bottom_sheet.xml +++ b/app/src/main/res/layout/episode_bottom_sheet.xml @@ -221,7 +221,9 @@ android:contentDescription="@string/delete_button_description" android:padding="12dp" android:src="@drawable/ic_trash" - android:visibility="gone" /> + android:visibility="gone" + app:tint="?attr/colorOnSecondaryContainer" + tools:visibility="visible" /> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/app_navigation.xml b/app/src/main/res/navigation/app_navigation.xml index 3027ec19..a83d5497 100644 --- a/app/src/main/res/navigation/app_navigation.xml +++ b/app/src/main/res/navigation/app_navigation.xml @@ -163,6 +163,23 @@ android:id="@+id/action_seasonFragment_to_episodeBottomSheetFragment" app:destination="@id/episodeBottomSheetFragment" /> + + + + + +