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 <jarnedemeulemeester@gmail.com>
This commit is contained in:
parent
795917d9d1
commit
c1740c1b68
16 changed files with 438 additions and 67 deletions
|
@ -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
|
||||
|
|
|
@ -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<PlayerItem, DownloadEpisodeListAdapter.EpisodeViewHolder>(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<DownloadEpisodeItem, RecyclerView.ViewHolder>(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<PlayerItem>() {
|
||||
override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
|
||||
return oldItem.itemId == newItem.itemId
|
||||
companion object DiffCallback : DiffUtil.ItemCallback<DownloadEpisodeItem>() {
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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<DownloadSeriesMetadata, DownloadSeriesListAdapter.ItemViewHolder>(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<DownloadSeriesMetadata>() {
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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<DownloadSection, DownloadsListAdapter.SectionViewHolder>(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)
|
||||
}
|
||||
}
|
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -5,5 +5,6 @@ import java.util.*
|
|||
data class DownloadSection(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
var items: List<PlayerItem>
|
||||
val items: List<PlayerItem>? = null,
|
||||
val series: List<DownloadSeriesMetadata>? = null
|
||||
)
|
|
@ -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<PlayerItem>
|
||||
) : Parcelable
|
|
@ -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
|
||||
|
|
|
@ -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>(UiState.Loading)
|
||||
|
||||
sealed class UiState {
|
||||
data class Normal(val downloadEpisodes: List<DownloadEpisodeItem>) : 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<DownloadEpisodeItem> {
|
||||
val episodes = seriesMetadata.episodes
|
||||
return listOf(DownloadEpisodeItem.Header) + episodes.sortedWith(compareBy(
|
||||
{ it.item!!.parentIndexNumber },
|
||||
{ it.item!!.indexNumber })).map { DownloadEpisodeItem.Episode(it) }
|
||||
}
|
||||
}
|
|
@ -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<UUID, MutableList<PlayerItem>>()
|
||||
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<DownloadSection>()
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
|
|
36
app/src/main/res/layout/fragment_download_series.xml
Normal file
36
app/src/main/res/layout/fragment_download_series.xml
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:context=".fragments.SeasonFragment">
|
||||
|
||||
<data>
|
||||
|
||||
<variable
|
||||
name="viewModel"
|
||||
type="dev.jdtech.jellyfin.viewmodels.DownloadSeriesViewModel" />
|
||||
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<include
|
||||
android:id="@+id/error_layout"
|
||||
layout="@layout/error_panel" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/episodes_recycler_view"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:itemCount="4"
|
||||
tools:listitem="@layout/episode_item" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</layout>
|
|
@ -163,6 +163,23 @@
|
|||
android:id="@+id/action_seasonFragment_to_episodeBottomSheetFragment"
|
||||
app:destination="@id/episodeBottomSheetFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/downloadSeriesFragment"
|
||||
android:name="dev.jdtech.jellyfin.fragments.DownloadSeriesFragment"
|
||||
android:label="{seriesName}"
|
||||
tools:layout="@layout/fragment_season">
|
||||
<argument
|
||||
android:name="seriesMetadata"
|
||||
app:argType="dev.jdtech.jellyfin.models.DownloadSeriesMetadata"/>
|
||||
<argument
|
||||
android:name="seriesName"
|
||||
android:defaultValue="Series"
|
||||
app:argType="string"
|
||||
app:nullable="true" />
|
||||
<action
|
||||
android:id="@+id/action_downloadSeriesFragment_to_episodeBottomSheetFragment"
|
||||
app:destination="@id/episodeBottomSheetFragment" />
|
||||
</fragment>
|
||||
<dialog
|
||||
android:id="@+id/episodeBottomSheetFragment"
|
||||
android:name="dev.jdtech.jellyfin.fragments.EpisodeBottomSheetFragment"
|
||||
|
@ -207,6 +224,9 @@
|
|||
<action
|
||||
android:id="@+id/action_downloadFragment_to_mediaInfoFragment"
|
||||
app:destination="@id/mediaInfoFragment" />
|
||||
<action
|
||||
android:id="@+id/action_downloadFragment_to_downloadSeriesFragment"
|
||||
app:destination="@id/downloadSeriesFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/searchResultFragment"
|
||||
|
|
Loading…
Reference in a new issue