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
|
- Completely native interface
|
||||||
- Supported media items: movies, series, seasons, episodes
|
- Supported media items: movies, series, seasons, episodes
|
||||||
- Direct play only, (no transcoding)
|
- Direct play only, (no transcoding)
|
||||||
|
- Offline playback / downloads
|
||||||
- ExoPlayer
|
- ExoPlayer
|
||||||
- Video codecs: H.263, H.264, H.265, VP8, VP9, AV1
|
- Video codecs: H.263, H.264, H.265, VP8, VP9, AV1
|
||||||
- Support depends on Android device
|
- Support depends on Android device
|
||||||
|
|
|
@ -7,63 +7,122 @@ import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding
|
import dev.jdtech.jellyfin.databinding.EpisodeItemBinding
|
||||||
import dev.jdtech.jellyfin.models.ContentType
|
import dev.jdtech.jellyfin.databinding.SeasonHeaderBinding
|
||||||
|
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
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) {
|
private const val ITEM_VIEW_TYPE_HEADER = 0
|
||||||
class EpisodeViewHolder(private var binding: HomeEpisodeItemBinding) :
|
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) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(episode: PlayerItem) {
|
fun bind(
|
||||||
val metadata = episode.item!!
|
metadata: DownloadSeriesMetadata
|
||||||
binding.episode = downloadMetadataToBaseItemDto(episode.item)
|
) {
|
||||||
if (metadata.playedPercentage != null) {
|
binding.seasonName.text = metadata.name
|
||||||
binding.progressBar.layoutParams.width = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
|
binding.seriesId = metadata.itemId
|
||||||
(metadata.playedPercentage.times(2.24)).toFloat(), binding.progressBar.context.resources.displayMetrics).toInt()
|
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
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
}
|
} else {
|
||||||
if (metadata.type == ContentType.MOVIE) {
|
binding.progressBar.visibility = View.GONE
|
||||||
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
|
|
||||||
}
|
}
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<PlayerItem>() {
|
companion object DiffCallback : DiffUtil.ItemCallback<DownloadEpisodeItem>() {
|
||||||
override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
|
override fun areItemsTheSame(oldItem: DownloadEpisodeItem, newItem: DownloadEpisodeItem): Boolean {
|
||||||
return oldItem.itemId == newItem.itemId
|
return oldItem.id == newItem.id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
|
override fun areContentsTheSame(oldItem: DownloadEpisodeItem, newItem: DownloadEpisodeItem): Boolean {
|
||||||
return oldItem == newItem
|
return oldItem == newItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
return EpisodeViewHolder(
|
return when (viewType) {
|
||||||
HomeEpisodeItemBinding.inflate(
|
ITEM_VIEW_TYPE_HEADER -> {
|
||||||
LayoutInflater.from(parent.context),
|
HeaderViewHolder(
|
||||||
parent,
|
SeasonHeaderBinding.inflate(
|
||||||
false
|
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) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
val item = getItem(position)
|
when (holder.itemViewType) {
|
||||||
holder.itemView.setOnClickListener {
|
ITEM_VIEW_TYPE_HEADER -> {
|
||||||
onClickListener.onClick(item)
|
(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) {
|
class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) {
|
||||||
fun onClick(item: PlayerItem) = clickListener(item)
|
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 androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.jdtech.jellyfin.R
|
import dev.jdtech.jellyfin.R
|
||||||
import dev.jdtech.jellyfin.databinding.BaseItemBinding
|
import dev.jdtech.jellyfin.databinding.BaseItemBinding
|
||||||
import dev.jdtech.jellyfin.models.ContentType
|
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
||||||
|
|
||||||
|
@ -23,11 +22,10 @@ class DownloadViewItemListAdapter(
|
||||||
fun bind(item: PlayerItem, fixedWidth: Boolean) {
|
fun bind(item: PlayerItem, fixedWidth: Boolean) {
|
||||||
val metadata = item.item!!
|
val metadata = item.item!!
|
||||||
binding.item = downloadMetadataToBaseItemDto(metadata)
|
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
|
binding.itemCount.visibility = View.GONE
|
||||||
if (fixedWidth) {
|
if (fixedWidth) {
|
||||||
binding.itemLayout.layoutParams.width =
|
binding.itemLayout.layoutParams.width = parent.resources.getDimension(R.dimen.overview_media_width).toInt()
|
||||||
parent.resources.getDimension(R.dimen.overview_media_width).toInt()
|
|
||||||
(binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
|
(binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
|
||||||
}
|
}
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
|
|
|
@ -10,24 +10,25 @@ import dev.jdtech.jellyfin.models.DownloadSection
|
||||||
|
|
||||||
class DownloadsListAdapter(
|
class DownloadsListAdapter(
|
||||||
private val onClickListener: DownloadViewItemListAdapter.OnClickListener,
|
private val onClickListener: DownloadViewItemListAdapter.OnClickListener,
|
||||||
private val onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener
|
private val onSeriesClickListener: DownloadSeriesListAdapter.OnClickListener
|
||||||
) : ListAdapter<DownloadSection, DownloadsListAdapter.SectionViewHolder>(DiffCallback) {
|
) : ListAdapter<DownloadSection, DownloadsListAdapter.SectionViewHolder>(DiffCallback) {
|
||||||
class SectionViewHolder(private var binding: DownloadSectionBinding) :
|
class SectionViewHolder(private var binding: DownloadSectionBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(
|
fun bind(
|
||||||
section: DownloadSection,
|
section: DownloadSection,
|
||||||
onClickListener: DownloadViewItemListAdapter.OnClickListener,
|
onClickListener: DownloadViewItemListAdapter.OnClickListener,
|
||||||
onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener
|
onSeriesClickListener: DownloadSeriesListAdapter.OnClickListener
|
||||||
) {
|
) {
|
||||||
binding.section = section
|
binding.section = section
|
||||||
if (section.name == "Movies" || section.name == "Shows") {
|
when (section.name) {
|
||||||
binding.itemsRecyclerView.adapter =
|
"Movies" -> {
|
||||||
DownloadViewItemListAdapter(onClickListener, fixedWidth = true)
|
binding.itemsRecyclerView.adapter = DownloadViewItemListAdapter(onClickListener, fixedWidth = true)
|
||||||
(binding.itemsRecyclerView.adapter as DownloadViewItemListAdapter).submitList(section.items)
|
(binding.itemsRecyclerView.adapter as DownloadViewItemListAdapter).submitList(section.items)
|
||||||
} else if (section.name == "Episodes") {
|
}
|
||||||
binding.itemsRecyclerView.adapter =
|
"Shows" -> {
|
||||||
DownloadEpisodeListAdapter(onEpisodeClickListener)
|
binding.itemsRecyclerView.adapter = DownloadSeriesListAdapter(onSeriesClickListener, fixedWidth = true)
|
||||||
(binding.itemsRecyclerView.adapter as DownloadEpisodeListAdapter).submitList(section.items)
|
(binding.itemsRecyclerView.adapter as DownloadSeriesListAdapter).submitList(section.series)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
|
@ -58,6 +59,6 @@ class DownloadsListAdapter(
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
|
||||||
val collection = getItem(position)
|
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.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import dev.jdtech.jellyfin.models.DownloadItem
|
import dev.jdtech.jellyfin.models.DownloadItem
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
|
@ -15,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.adapters.*
|
import dev.jdtech.jellyfin.adapters.*
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentDownloadBinding
|
import dev.jdtech.jellyfin.databinding.FragmentDownloadBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.DownloadViewModel
|
import dev.jdtech.jellyfin.viewmodels.DownloadViewModel
|
||||||
|
@ -39,9 +40,10 @@ class DownloadFragment : Fragment() {
|
||||||
binding.downloadsRecyclerView.adapter = DownloadsListAdapter(
|
binding.downloadsRecyclerView.adapter = DownloadsListAdapter(
|
||||||
DownloadViewItemListAdapter.OnClickListener { item ->
|
DownloadViewItemListAdapter.OnClickListener { item ->
|
||||||
navigateToMediaInfoFragment(item)
|
navigateToMediaInfoFragment(item)
|
||||||
}, DownloadEpisodeListAdapter.OnClickListener { item ->
|
}, DownloadSeriesListAdapter.OnClickListener { item ->
|
||||||
navigateToEpisodeBottomSheetFragment(item)
|
navigateToDownloadSeriesFragment(item)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
@ -104,12 +106,11 @@ class DownloadFragment : Fragment() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToEpisodeBottomSheetFragment(episode: PlayerItem) {
|
private fun navigateToDownloadSeriesFragment(series: DownloadSeriesMetadata) {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
DownloadFragmentDirections.actionDownloadFragmentToEpisodeBottomSheetFragment(
|
DownloadFragmentDirections.actionDownloadFragmentToDownloadSeriesFragment(
|
||||||
UUID.randomUUID(),
|
seriesMetadata = series,
|
||||||
episode,
|
seriesName = series.name
|
||||||
isOffline = true
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
data class DownloadSection(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val name: String,
|
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.database.DownloadDatabaseDao
|
||||||
import dev.jdtech.jellyfin.models.DownloadItem
|
import dev.jdtech.jellyfin.models.DownloadItem
|
||||||
import dev.jdtech.jellyfin.models.DownloadRequestItem
|
import dev.jdtech.jellyfin.models.DownloadRequestItem
|
||||||
|
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
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(
|
suspend fun syncPlaybackProgress(
|
||||||
downloadDatabase: DownloadDatabaseDao,
|
downloadDatabase: DownloadDatabaseDao,
|
||||||
jellyfinRepository: JellyfinRepository
|
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.database.DownloadDatabaseDao
|
||||||
import dev.jdtech.jellyfin.models.ContentType
|
import dev.jdtech.jellyfin.models.ContentType
|
||||||
import dev.jdtech.jellyfin.models.DownloadSection
|
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 dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
@ -39,21 +41,31 @@ constructor(
|
||||||
uiState.emit(UiState.Loading)
|
uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
val items = loadDownloadedEpisodes(downloadDatabase)
|
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>()
|
val downloadSections = mutableListOf<DownloadSection>()
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
DownloadSection(
|
DownloadSection(
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Episodes",
|
"Movies",
|
||||||
items.filter { it.item?.type == ContentType.EPISODE }).let {
|
items.filter { it.item?.type == ContentType.MOVIE }
|
||||||
if (it.items.isNotEmpty()) downloadSections.add(
|
).let {
|
||||||
|
if (it.items!!.isNotEmpty()) downloadSections.add(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DownloadSection(
|
DownloadSection(
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Movies",
|
"Shows",
|
||||||
items.filter { it.item?.type == ContentType.MOVIE }).let {
|
null,
|
||||||
if (it.items.isNotEmpty()) downloadSections.add(
|
shows
|
||||||
|
).let {
|
||||||
|
if (it.series!!.isNotEmpty()) downloadSections.add(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -221,7 +221,9 @@
|
||||||
android:contentDescription="@string/delete_button_description"
|
android:contentDescription="@string/delete_button_description"
|
||||||
android:padding="12dp"
|
android:padding="12dp"
|
||||||
android:src="@drawable/ic_trash"
|
android:src="@drawable/ic_trash"
|
||||||
android:visibility="gone" />
|
android:visibility="gone"
|
||||||
|
app:tint="?attr/colorOnSecondaryContainer"
|
||||||
|
tools:visibility="visible" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<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"
|
android:id="@+id/action_seasonFragment_to_episodeBottomSheetFragment"
|
||||||
app:destination="@id/episodeBottomSheetFragment" />
|
app:destination="@id/episodeBottomSheetFragment" />
|
||||||
</fragment>
|
</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
|
<dialog
|
||||||
android:id="@+id/episodeBottomSheetFragment"
|
android:id="@+id/episodeBottomSheetFragment"
|
||||||
android:name="dev.jdtech.jellyfin.fragments.EpisodeBottomSheetFragment"
|
android:name="dev.jdtech.jellyfin.fragments.EpisodeBottomSheetFragment"
|
||||||
|
@ -207,6 +224,9 @@
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_downloadFragment_to_mediaInfoFragment"
|
android:id="@+id/action_downloadFragment_to_mediaInfoFragment"
|
||||||
app:destination="@id/mediaInfoFragment" />
|
app:destination="@id/mediaInfoFragment" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_downloadFragment_to_downloadSeriesFragment"
|
||||||
|
app:destination="@id/downloadSeriesFragment" />
|
||||||
</fragment>
|
</fragment>
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/searchResultFragment"
|
android:id="@+id/searchResultFragment"
|
||||||
|
|
Loading…
Reference in a new issue