diff --git a/.idea/gradle.properties b/.idea/gradle.properties
new file mode 100644
index 00000000..e69de29b
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ebc573e1..447b4ca6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,7 +3,8 @@
package="dev.jdtech.jellyfin">
-
+
{
if (!episode.backdropImageTags.isNullOrEmpty()) {
@@ -177,4 +169,10 @@ fun bindSeasonPoster(imageView: ImageView, seasonId: UUID) {
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)
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
index af05e8a9..76ce9bb1 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
@@ -42,7 +42,7 @@ class MainActivity : AppCompatActivity() {
// menu should be considered as top level destinations.
val appBarConfiguration = AppBarConfiguration(
setOf(
- R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment
+ R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment, R.id.downloadFragment
)
)
diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt
new file mode 100644
index 00000000..be1c8527
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt
@@ -0,0 +1,68 @@
+package dev.jdtech.jellyfin.adapters
+
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+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.PlayerItem
+import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
+import timber.log.Timber
+
+class DownloadEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter(DiffCallback) {
+ class EpisodeViewHolder(private var binding: HomeEpisodeItemBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(episode: PlayerItem) {
+ val metadata = episode.metadata!!
+ binding.episode = downloadMetadataToBaseItemDto(episode.metadata)
+ 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()
+ binding.progressBar.visibility = View.VISIBLE
+ }
+ if (metadata.type == "Movie") {
+ binding.primaryName.text = metadata.name
+ Timber.d(metadata.name)
+ binding.secondaryName.visibility = View.GONE
+ } else if (metadata.type == "Episode") {
+ binding.primaryName.text = metadata.seriesName
+ }
+ binding.executePendingBindings()
+ }
+ }
+
+ companion object DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
+ return oldItem.itemId == newItem.itemId
+ }
+
+ override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
+ return oldItem == newItem
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder {
+ return EpisodeViewHolder(
+ HomeEpisodeItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.itemView.setOnClickListener {
+ onClickListener.onClick(item)
+ }
+ holder.bind(item)
+ }
+
+ class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) {
+ fun onClick(item: PlayerItem) = 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
new file mode 100644
index 00000000..b1c4ee4d
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt
@@ -0,0 +1,67 @@
+package dev.jdtech.jellyfin.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+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.PlayerItem
+import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
+
+class DownloadViewItemListAdapter(
+ 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: PlayerItem, fixedWidth: Boolean) {
+ val metadata = item.metadata!!
+ binding.item = downloadMetadataToBaseItemDto(metadata)
+ binding.itemName.text = if (metadata.type == "Episode") metadata.seriesName else 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 as ViewGroup.MarginLayoutParams).bottomMargin = 0
+ }
+ binding.executePendingBindings()
+ }
+ }
+
+ companion object DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
+ return oldItem.itemId == newItem.itemId
+ }
+
+ override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): 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: PlayerItem) -> Unit) {
+ fun onClick(item: PlayerItem) = clickListener(item)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt
new file mode 100644
index 00000000..9a0866b9
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt
@@ -0,0 +1,63 @@
+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.databinding.DownloadSectionBinding
+import dev.jdtech.jellyfin.models.DownloadSection
+
+class DownloadsListAdapter(
+ private val onClickListener: DownloadViewItemListAdapter.OnClickListener,
+ private val onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener
+) : ListAdapter(DiffCallback) {
+ class SectionViewHolder(private var binding: DownloadSectionBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(
+ section: DownloadSection,
+ onClickListener: DownloadViewItemListAdapter.OnClickListener,
+ onEpisodeClickListener: DownloadEpisodeListAdapter.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)
+ }
+ binding.executePendingBindings()
+ }
+ }
+
+ companion object DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: DownloadSection, newItem: DownloadSection): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(
+ oldItem: DownloadSection,
+ newItem: DownloadSection
+ ): Boolean {
+ return oldItem == newItem
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
+ return SectionViewHolder(
+ DownloadSectionBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
+ val collection = getItem(position)
+ holder.bind(collection, onClickListener, onEpisodeClickListener)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt
new file mode 100644
index 00000000..96fdcd9d
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt
@@ -0,0 +1,100 @@
+package dev.jdtech.jellyfin.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+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.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 java.util.*
+
+@AndroidEntryPoint
+class DownloadFragment : Fragment() {
+
+ private lateinit var binding: FragmentDownloadBinding
+ private val viewModel: DownloadViewModel by viewModels()
+
+ 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)
+ }, DownloadEpisodeListAdapter.OnClickListener { item ->
+ 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
+ }
+ })
+
+ binding.errorLayout.errorRetryButton.setOnClickListener {
+ viewModel.loadData()
+ }
+
+ binding.errorLayout.errorDetailsButton.setOnClickListener {
+ ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).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 navigateToMediaInfoFragment(item: PlayerItem) {
+ findNavController().navigate(
+ DownloadFragmentDirections.actionDownloadFragmentToMediaInfoFragment(
+ UUID.randomUUID(),
+ item.name,
+ item.metadata?.type ?: "Unknown",
+ item,
+ isOffline = true
+ )
+ )
+ }
+
+ private fun navigateToEpisodeBottomSheetFragment(episode: PlayerItem) {
+ findNavController().navigate(
+ DownloadFragmentDirections.actionDownloadFragmentToEpisodeBottomSheetFragment(
+ UUID.randomUUID(),
+ episode,
+ isOffline = true
+ )
+ )
+ }
+}
\ No newline at end of file
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 c87fbc6d..26043ced 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt
@@ -1,5 +1,6 @@
package dev.jdtech.jellyfin.fragments
+import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
@@ -17,9 +18,11 @@ import dev.jdtech.jellyfin.R
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 timber.log.Timber
+import java.util.*
@AndroidEntryPoint
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
@@ -43,7 +46,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE
viewModel.item.value?.let {
- playerViewModel.loadPlayerItems(it)
+ if (!args.isOffline) {
+ playerViewModel.loadPlayerItems(it)
+ } else {
+ playerViewModel.loadOfflinePlayerItems(viewModel.playerItems[0])
+ }
}
}
@@ -54,20 +61,6 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}
}
- binding.checkButton.setOnClickListener {
- when (viewModel.played.value) {
- true -> viewModel.markAsUnplayed(args.episodeId)
- false -> viewModel.markAsPlayed(args.episodeId)
- }
- }
-
- binding.favoriteButton.setOnClickListener {
- when (viewModel.favorite.value) {
- true -> viewModel.unmarkAsFavorite(args.episodeId)
- false -> viewModel.markAsFavorite(args.episodeId)
- }
- }
-
viewModel.item.observe(viewLifecycleOwner, { episode ->
if (episode.userData?.playedPercentage != null) {
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
@@ -101,7 +94,50 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.favoriteButton.setImageResource(drawable)
})
- viewModel.loadEpisode(args.episodeId)
+ viewModel.downloadEpisode.observe(viewLifecycleOwner, {
+ if (it) {
+ requestDownload(Uri.parse(viewModel.downloadRequestItem.uri), viewModel.downloadRequestItem, this)
+ viewModel.doneDownloadEpisode()
+ }
+ })
+
+ if(!args.isOffline){
+ val episodeId: UUID = args.episodeId
+ binding.checkButton.setOnClickListener {
+ when (viewModel.played.value) {
+ true -> viewModel.markAsUnplayed(episodeId)
+ false -> viewModel.markAsPlayed(episodeId)
+ }
+ }
+
+ binding.favoriteButton.setOnClickListener {
+ when (viewModel.favorite.value) {
+ true -> viewModel.unmarkAsFavorite(episodeId)
+ false -> viewModel.markAsFavorite(episodeId)
+ }
+ }
+
+ binding.downloadButton.setOnClickListener {
+ viewModel.loadDownloadRequestItem(episodeId)
+ }
+
+ binding.deleteButton.visibility = View.GONE
+
+ viewModel.loadEpisode(episodeId)
+ }else {
+ val playerItem = args.playerItem!!
+ viewModel.loadEpisode(playerItem)
+
+ binding.deleteButton.setOnClickListener {
+ viewModel.deleteEpisode()
+ dismiss()
+ findNavController().navigate(R.id.downloadFragment)
+ }
+
+ binding.checkButton.visibility = View.GONE
+ binding.favoriteButton.visibility = View.GONE
+ binding.downloadButton.visibility = View.GONE
+ }
return binding.root
}
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 74c00cb6..f6505c49 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
@@ -22,6 +22,7 @@ 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 org.jellyfin.sdk.model.api.BaseItemDto
@@ -65,10 +66,21 @@ class MediaInfoFragment : Fragment() {
}
})
+ if(args.itemType != "Movie") {
+ binding.downloadButton.visibility = View.GONE
+ }
+
binding.errorLayout.errorRetryButton.setOnClickListener {
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
@@ -82,6 +94,7 @@ class MediaInfoFragment : Fragment() {
true -> View.VISIBLE
false -> View.GONE
}
+ Timber.d(item.seasonId.toString())
})
viewModel.actors.observe(viewLifecycleOwner, { actors ->
@@ -147,30 +160,57 @@ class MediaInfoFragment : Fragment() {
binding.progressCircular.visibility = View.VISIBLE
viewModel.item.value?.let { item ->
- playerViewModel.loadPlayerItems(item) {
- VideoVersionDialogFragment(item, playerViewModel).show(
- parentFragmentManager,
- "videoversiondialog"
- )
+ if (!args.isOffline) {
+ playerViewModel.loadPlayerItems(item) {
+ VideoVersionDialogFragment(item, playerViewModel).show(
+ parentFragmentManager,
+ "videoversiondialog"
+ )
+ }
+ } else {
+ playerViewModel.loadOfflinePlayerItems(args.playerItem!!)
}
}
}
- binding.checkButton.setOnClickListener {
- when (viewModel.played.value) {
- true -> viewModel.markAsUnplayed(args.itemId)
- false -> viewModel.markAsPlayed(args.itemId)
+ if (!args.isOffline) {
+ binding.errorLayout.errorRetryButton.setOnClickListener {
+ viewModel.loadData(args.itemId, args.itemType)
}
- }
- binding.favoriteButton.setOnClickListener {
- when (viewModel.favorite.value) {
- true -> viewModel.unmarkAsFavorite(args.itemId)
- false -> viewModel.markAsFavorite(args.itemId)
+ binding.checkButton.setOnClickListener {
+ when (viewModel.played.value) {
+ true -> viewModel.markAsUnplayed(args.itemId)
+ false -> viewModel.markAsPlayed(args.itemId)
+ }
}
- }
- viewModel.loadData(args.itemId, args.itemType)
+ binding.favoriteButton.setOnClickListener {
+ when (viewModel.favorite.value) {
+ true -> viewModel.unmarkAsFavorite(args.itemId)
+ false -> viewModel.markAsFavorite(args.itemId)
+ }
+ }
+
+ binding.downloadButton.setOnClickListener {
+ viewModel.loadDownloadRequestItem(args.itemId)
+ }
+
+ binding.deleteButton.visibility = View.GONE
+
+ viewModel.loadData(args.itemId, args.itemType)
+ } else {
+ binding.favoriteButton.visibility = View.GONE
+ binding.checkButton.visibility = View.GONE
+ binding.downloadButton.visibility = View.GONE
+
+ binding.deleteButton.setOnClickListener {
+ viewModel.deleteItem()
+ findNavController().navigate(R.id.downloadFragment)
+ }
+
+ viewModel.loadData(args.playerItem!!)
+ }
}
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
@@ -186,7 +226,6 @@ class MediaInfoFragment : Fragment() {
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
Timber.e(error.message)
-
binding.playerItemsError.visibility = View.VISIBLE
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
@@ -195,7 +234,7 @@ class MediaInfoFragment : Fragment() {
)
)
binding.progressCircular.visibility = View.INVISIBLE
- binding.errorLayout.errorDetailsButton.setOnClickListener {
+ binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
}
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt
new file mode 100644
index 00000000..b61fd432
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt
@@ -0,0 +1,18 @@
+package dev.jdtech.jellyfin.models
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import java.util.*
+
+@Parcelize
+data class DownloadMetadata(
+ val id: UUID,
+ val type: String?,
+ val seriesName: String? = null,
+ val name: String? = null,
+ val parentIndexNumber: Int? = null,
+ val indexNumber: Int? = null,
+ val playbackPosition: Long? = null,
+ val playedPercentage: Double? = null,
+ val seriesId: UUID? = null
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt
new file mode 100644
index 00000000..32ad1e02
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt
@@ -0,0 +1,12 @@
+package dev.jdtech.jellyfin.models
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import java.util.*
+
+@Parcelize
+data class DownloadRequestItem(
+ val uri: String,
+ val itemId: UUID,
+ val metadata: DownloadMetadata
+) : Parcelable
\ 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
new file mode 100644
index 00000000..232ca9e6
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSection.kt
@@ -0,0 +1,9 @@
+package dev.jdtech.jellyfin.models
+
+import java.util.*
+
+data class DownloadSection(
+ val id: UUID,
+ val name: String,
+ var items: List
+)
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt b/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt
index 0df53cf7..aad7a3c8 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt
@@ -9,5 +9,7 @@ data class PlayerItem(
val name: String?,
val itemId: UUID,
val mediaSourceId: String,
- val playbackPosition: Long
+ val playbackPosition: Long,
+ val mediaSourceUri: String = "",
+ val metadata: DownloadMetadata? = null
) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt b/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt
index 2e60ac3e..fdd81ba0 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt
@@ -32,6 +32,7 @@ import kotlinx.parcelize.Parcelize
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
+import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.lang.IllegalArgumentException
diff --git a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt
new file mode 100644
index 00000000..d12d4af4
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt
@@ -0,0 +1,247 @@
+package dev.jdtech.jellyfin.utils
+
+import android.Manifest
+import android.app.DownloadManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import androidx.fragment.app.Fragment
+import dev.jdtech.jellyfin.R
+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 kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.UserItemDataDto
+import timber.log.Timber
+import java.io.File
+import java.util.*
+
+fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Fragment) {
+ // Storage permission for downloads isn't necessary from Android 10 onwards
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
+ @Suppress("MagicNumber")
+ Timber.d("REQUESTING PERMISSION")
+
+ if (ContextCompat.checkSelfPermission(context.requireActivity(),
+ Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
+ ) {
+ if (ActivityCompat.shouldShowRequestPermissionRationale(context.requireActivity(),
+ Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ ActivityCompat.requestPermissions(context.requireActivity(),
+ arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
+ } else {
+ ActivityCompat.requestPermissions(context.requireActivity(),
+ arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
+ }
+ }
+
+ val granted = ContextCompat.checkSelfPermission(context.requireActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
+
+ if (!granted) {
+ context.requireContext().toast(R.string.download_no_storage_permission)
+ return
+ }
+ }
+ val defaultStorage = getDownloadLocation(context.requireContext())
+ Timber.d(defaultStorage.toString())
+ val downloadRequest = DownloadManager.Request(uri)
+ .setTitle(downloadRequestItem.metadata.name)
+ .setDescription("Downloading")
+ .setDestinationUri(Uri.fromFile(File(defaultStorage, downloadRequestItem.itemId.toString())))
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ if(!File(defaultStorage, downloadRequestItem.itemId.toString()).exists())
+ downloadFile(downloadRequest, 1, context.requireContext())
+ createMetadataFile(downloadRequestItem.metadata, downloadRequestItem.itemId, context.requireContext())
+}
+
+private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID, context: Context) {
+ val defaultStorage = getDownloadLocation(context)
+ val metadataFile = File(defaultStorage, "${itemId}.metadata")
+
+ metadataFile.writeText("") //This might be necessary to make sure that the metadata file is empty
+
+ if(metadata.type == "Episode") {
+ metadataFile.printWriter().use { out ->
+ out.println(metadata.id)
+ out.println(metadata.type.toString())
+ out.println(metadata.seriesName.toString())
+ out.println(metadata.name.toString())
+ out.println(metadata.parentIndexNumber.toString())
+ out.println(metadata.indexNumber.toString())
+ out.println(metadata.playbackPosition.toString())
+ out.println(metadata.playedPercentage.toString())
+ out.println(metadata.seriesId.toString())
+ }
+ } else if (metadata.type == "Movie") {
+ metadataFile.printWriter().use { out ->
+ out.println(metadata.id)
+ out.println(metadata.type.toString())
+ out.println(metadata.name.toString())
+ out.println(metadata.playbackPosition.toString())
+ out.println(metadata.playedPercentage.toString())
+ }
+ }
+
+}
+
+private fun downloadFile(request: DownloadManager.Request, downloadMethod: Int, context: Context) {
+ require(downloadMethod >= 0) { "Download method hasn't been set" }
+ request.apply {
+ setAllowedOverMetered(false)
+ setAllowedOverRoaming(false)
+ }
+ context.getSystemService()?.enqueue(request)
+}
+
+private fun getDownloadLocation(context: Context): File? {
+ return context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
+}
+
+fun loadDownloadedEpisodes(context: Context): List {
+ val items = mutableListOf()
+ val defaultStorage = getDownloadLocation(context)
+ defaultStorage?.walk()?.forEach {
+ if (it.isFile && it.extension == "") {
+ try{
+ val metadataFile = File(defaultStorage, "${it.name}.metadata").readLines()
+ val metadata = parseMetadataFile(metadataFile)
+ items.add(PlayerItem(metadata.name, UUID.fromString(it.name), "", metadata.playbackPosition!!, it.absolutePath, metadata))
+ } catch (e: Exception) {
+ it.delete()
+ Timber.e(e)
+ }
+
+ }
+ }
+ return items.toList()
+}
+
+fun deleteDownloadedEpisode(uri: String) {
+ try {
+ File(uri).delete()
+ File("${uri}.metadata").delete()
+ } catch (e: Exception) {
+ Timber.e(e)
+ }
+
+}
+
+fun postDownloadPlaybackProgress(uri: String, playbackPosition: Long, playedPercentage: Double) {
+ try {
+ val metadataFile = File("${uri}.metadata")
+ val metadataArray = metadataFile.readLines().toMutableList()
+ if(metadataArray[1] == "Episode"){
+ metadataArray[6] = playbackPosition.toString()
+ metadataArray[7] = playedPercentage.toString()
+ } else if (metadataArray[1] == "Movie") {
+ metadataArray[3] = playbackPosition.toString()
+ metadataArray[4] = playedPercentage.toString()
+ }
+
+ metadataFile.writeText("") //This might be necessary to make sure that the metadata file is empty
+ metadataFile.printWriter().use { out ->
+ metadataArray.forEach {
+ out.println(it)
+ }
+ }
+ } catch (e: Exception) {
+ Timber.e(e)
+ }
+}
+
+fun downloadMetadataToBaseItemDto(metadata: DownloadMetadata) : BaseItemDto {
+ val userData = UserItemDataDto(playbackPositionTicks = metadata.playbackPosition ?: 0,
+ playedPercentage = metadata.playedPercentage, isFavorite = false, playCount = 0, played = false)
+
+ return BaseItemDto(id = metadata.id,
+ type = metadata.type,
+ seriesName = metadata.seriesName,
+ name = metadata.name,
+ parentIndexNumber = metadata.parentIndexNumber,
+ indexNumber = metadata.indexNumber,
+ userData = userData,
+ seriesId = metadata.seriesId
+ )
+}
+
+fun baseItemDtoToDownloadMetadata(item: BaseItemDto) : DownloadMetadata {
+ return DownloadMetadata(id = item.id,
+ type = item.type,
+ seriesName = item.seriesName,
+ name = item.name,
+ parentIndexNumber = item.parentIndexNumber,
+ indexNumber = item.indexNumber,
+ playbackPosition = item.userData?.playbackPositionTicks ?: 0,
+ playedPercentage = item.userData?.playedPercentage,
+ seriesId = item.seriesId
+ )
+}
+
+fun parseMetadataFile(metadataFile: List) : DownloadMetadata {
+ if (metadataFile[1] == "Episode") {
+ return DownloadMetadata(id = UUID.fromString(metadataFile[0]),
+ type = metadataFile[1],
+ seriesName = metadataFile[2],
+ name = metadataFile[3],
+ parentIndexNumber = metadataFile[4].toInt(),
+ indexNumber = metadataFile[5].toInt(),
+ playbackPosition = metadataFile[6].toLong(),
+ playedPercentage = if(metadataFile[7] == "null") {null} else {metadataFile[7].toDouble()},
+ seriesId = UUID.fromString(metadataFile[8])
+ )
+ } else {
+ return DownloadMetadata(id = UUID.fromString(metadataFile[0]),
+ type = metadataFile[1],
+ name = metadataFile[2],
+ playbackPosition = metadataFile[3].toLong(),
+ playedPercentage = if(metadataFile[4] == "null") {null} else {metadataFile[4].toDouble()},
+ )
+ }
+}
+
+suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository, context: Context) {
+ val items = loadDownloadedEpisodes(context)
+ items.forEach(){
+ try {
+ val localPlaybackProgress = it.metadata?.playbackPosition
+ val localPlayedPercentage = it.metadata?.playedPercentage
+
+ val item = jellyfinRepository.getItem(it.itemId)
+ val remotePlaybackProgress = item.userData?.playbackPositionTicks?.div(10000)
+ val remotePlayedPercentage = item.userData?.playedPercentage
+
+ var playbackProgress: Long = 0
+ var playedPercentage = 0.0
+
+ if (localPlaybackProgress != null) {
+ if (localPlaybackProgress > playbackProgress){
+ playbackProgress = localPlaybackProgress
+ playedPercentage = localPlayedPercentage!!
+ }
+ }
+ if (remotePlaybackProgress != null) {
+ if (remotePlaybackProgress > playbackProgress){
+ playbackProgress = remotePlaybackProgress
+ playedPercentage = remotePlayedPercentage!!
+ }
+ }
+
+ if (playbackProgress != 0.toLong()) {
+ postDownloadPlaybackProgress(it.mediaSourceUri, playbackProgress, playedPercentage)
+ jellyfinRepository.postPlaybackProgress(it.itemId, playbackProgress.times(10000), true)
+ Timber.d("Percentage: $playedPercentage")
+ }
+ } catch (e: Exception) {
+ Timber.e(e)
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt b/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt
index 6d300c93..272f17de 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt
@@ -1,5 +1,8 @@
package dev.jdtech.jellyfin.utils
+import android.content.Context
+import android.widget.Toast
+import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import dev.jdtech.jellyfin.MainNavigationDirections
@@ -27,4 +30,7 @@ fun Fragment.checkIfLoginRequired(error: String) {
Timber.d("Login required!")
findNavController().navigate(MainNavigationDirections.actionGlobalLoginFragment())
}
-}
\ No newline at end of file
+}
+
+inline fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) =
+ Toast.makeText(this, text, duration).show()
\ 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
new file mode 100644
index 00000000..c8166bdc
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt
@@ -0,0 +1,78 @@
+package dev.jdtech.jellyfin.viewmodels
+
+import android.annotation.SuppressLint
+import android.app.Application
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dev.jdtech.jellyfin.models.DownloadSection
+import dev.jdtech.jellyfin.repository.JellyfinRepository
+import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
+import dev.jdtech.jellyfin.utils.postDownloadPlaybackProgress
+import kotlinx.coroutines.*
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.util.*
+import javax.inject.Inject
+
+@HiltViewModel
+class DownloadViewModel
+@Inject
+constructor(
+ private val application: Application,
+) : ViewModel() {
+ private val _downloadSections = MutableLiveData>()
+ val downloadSections: LiveData> = _downloadSections
+
+ private val _finishedLoading = MutableLiveData()
+ val finishedLoading: LiveData = _finishedLoading
+
+ private val _error = MutableLiveData()
+ val error: LiveData = _error
+
+ init {
+ loadData()
+ }
+
+ @SuppressLint("ResourceType")
+ fun loadData() {
+ _error.value = null
+ _finishedLoading.value = false
+ viewModelScope.launch {
+ try {
+ val items = loadDownloadedEpisodes(application)
+ if (items.isEmpty()) {
+ _downloadSections.value = listOf()
+ _finishedLoading.value = true
+ 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
+ )
+ }
+ DownloadSection(
+ UUID.randomUUID(),
+ "Movies",
+ items.filter { it.metadata?.type == "Movie" }).let {
+ if (it.items.isNotEmpty()) tempDownloadSections.add(
+ it
+ )
+ }
+ }
+ _downloadSections.value = tempDownloadSections
+ } catch (e: Exception) {
+ Timber.e(e)
+ _error.value = e.toString()
+ }
+ _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 cba60e78..e5e8825d 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt
@@ -6,9 +6,17 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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 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
@@ -38,6 +46,13 @@ constructor(
private val _favorite = MutableLiveData()
val favorite: LiveData = _favorite
+ private val _downloadEpisode = MutableLiveData()
+ val downloadEpisode: LiveData = _downloadEpisode
+
+ var playerItems: MutableList = mutableListOf()
+
+ lateinit var downloadRequestItem: DownloadRequestItem
+
fun loadEpisode(episodeId: UUID) {
viewModelScope.launch {
try {
@@ -53,6 +68,11 @@ constructor(
}
}
+ fun loadEpisode(playerItem : PlayerItem){
+ playerItems.add(playerItem)
+ _item.value = downloadMetadataToBaseItemDto(playerItem.metadata!!)
+ }
+
fun markAsPlayed(itemId: UUID) {
viewModelScope.launch {
jellyfinRepository.markAsPlayed(itemId)
@@ -81,6 +101,21 @@ constructor(
_favorite.value = false
}
+ fun loadDownloadRequestItem(itemId: UUID) {
+ viewModelScope.launch {
+ loadEpisode(itemId)
+ val episode = _item.value
+ val uri = jellyfinRepository.getStreamUrl(itemId, episode?.mediaSources?.get(0)?.id!!)
+ val metadata = baseItemDtoToDownloadMetadata(episode)
+ downloadRequestItem = DownloadRequestItem(uri, itemId, metadata)
+ _downloadEpisode.value = true
+ }
+ }
+
+ fun deleteEpisode() {
+ deleteDownloadedEpisode(playerItems[0].mediaSourceUri)
+ }
+
private fun getDateString(item: BaseItemDto): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val instant = item.premiereDate?.toInstant(ZoneOffset.UTC)
@@ -91,4 +126,8 @@ constructor(
item.premiereDate.toString()
}
}
+
+ fun doneDownloadEpisode() {
+ _downloadEpisode.value = false
+ }
}
\ 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 0628f27a..97138202 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt
@@ -11,6 +11,7 @@ import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.models.HomeSection
import dev.jdtech.jellyfin.models.View
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.launch
@@ -24,7 +25,7 @@ import javax.inject.Inject
class HomeViewModel
@Inject
constructor(
- application: Application,
+ private val application: Application,
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
@@ -99,6 +100,10 @@ constructor(
}
}
+ withContext(Dispatchers.Default) {
+ syncPlaybackProgress(jellyfinRepository, application)
+ }
+
_views.value = items + views.map { HomeItem.ViewItem(it) }
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 54e3a94f..5f92d245 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt
@@ -6,7 +6,12 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
+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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -59,6 +64,13 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
private val _error = MutableLiveData()
val error: LiveData = _error
+ private val _downloadMedia = MutableLiveData()
+ val downloadMedia: LiveData = _downloadMedia
+
+ lateinit var downloadRequestItem: DownloadRequestItem
+
+ lateinit var playerItem: PlayerItem
+
fun loadData(itemId: UUID, itemType: String) {
_error.value = null
viewModelScope.launch {
@@ -85,6 +97,11 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
}
}
+ fun loadData(playerItem: PlayerItem) {
+ this.playerItem = playerItem
+ _item.value = downloadMetadataToBaseItemDto(playerItem.metadata!!)
+ }
+
private suspend fun getActors(item: BaseItemDto): List? {
val actors: List?
withContext(Dispatchers.Default) {
@@ -166,4 +183,23 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
else -> dateString
}
}
+
+ fun loadDownloadRequestItem(itemId: UUID) {
+ viewModelScope.launch {
+ val downloadItem = _item.value
+ val uri =
+ jellyfinRepository.getStreamUrl(itemId, downloadItem?.mediaSources?.get(0)?.id!!)
+ val metadata = baseItemDtoToDownloadMetadata(downloadItem)
+ downloadRequestItem = DownloadRequestItem(uri, itemId, metadata)
+ _downloadMedia.value = true
+ }
+ }
+
+ fun deleteItem() {
+ deleteDownloadedEpisode(playerItem.mediaSourceUri)
+ }
+
+ fun doneDownloadMedia() {
+ _downloadMedia.value = false
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt
index e24b42cc..ff1e4d5d 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt
@@ -20,6 +20,7 @@ import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.repository.JellyfinRepository
+import dev.jdtech.jellyfin.utils.postDownloadPlaybackProgress
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
@@ -51,6 +52,7 @@ constructor(
val trackSelector = DefaultTrackSelector(application)
var playWhenReady = true
+ private var playFromDownloads = false
private var currentWindow = 0
private var playbackPosition: Long = 0
@@ -60,7 +62,6 @@ constructor(
init {
val useMpv = sp.getBoolean("mpv_player", false)
-
val preferredAudioLanguage = sp.getString("audio_language", null) ?: ""
val preferredSubtitleLanguage = sp.getString("subtitle_language", null) ?: ""
@@ -100,10 +101,15 @@ constructor(
viewModelScope.launch {
val mediaItems: MutableList = mutableListOf()
-
try {
for (item in items) {
- val streamUrl = jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
+ playFromDownloads = item.mediaSourceUri.isNotEmpty()
+ val streamUrl = if(!playFromDownloads){
+ jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
+ }else{
+ item.mediaSourceUri
+ }
+
Timber.d("Stream url: $streamUrl")
val mediaItem =
MediaItem.Builder()
@@ -117,7 +123,9 @@ constructor(
}
player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition)
- player.prepare()
+ val useMpv = sp.getBoolean("mpv_player", false)
+ if(!useMpv || !playFromDownloads)
+ player.prepare() //TODO: This line causes a crash when playing from downloads with MPV
player.play()
pollPosition(player)
}
@@ -150,14 +158,17 @@ constructor(
override fun run() {
viewModelScope.launch {
if (player.currentMediaItem != null) {
- try {
- jellyfinRepository.postPlaybackProgress(
- UUID.fromString(player.currentMediaItem!!.mediaId),
- player.currentPosition.times(10000),
- !player.isPlaying
- )
- } catch (e: Exception) {
- Timber.e(e)
+ try {
+ jellyfinRepository.postPlaybackProgress(
+ UUID.fromString(player.currentMediaItem!!.mediaId),
+ player.currentPosition.times(10000),
+ !player.isPlaying
+ )
+ } catch (e: Exception) {
+ Timber.e(e)
+ }
+ if(playFromDownloads){
+ postDownloadPlaybackProgress(items[0].mediaSourceUri, player.currentPosition, (player.currentPosition.toDouble()/player.duration.toDouble()).times(100)) //TODO Automaticcaly use the correct item
}
}
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt
index 19d653ef..a4e819d5 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt
@@ -21,7 +21,11 @@ class PlayerViewModel @Inject internal constructor(
private val repository: JellyfinRepository
) : ViewModel() {
- private val playerItems = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+ private val playerItems = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
fun onPlaybackRequested(scope: LifecycleCoroutineScope, collector: (PlayerItemState) -> Unit) {
scope.launch { playerItems.collect { collector(it) } }
@@ -43,13 +47,20 @@ class PlayerViewModel @Inject internal constructor(
val items = try {
createItems(item, playbackPosition, mediaSourceIndex).let(::PlayerItems)
} catch (e: Exception) {
- PlayerItemError(e.message.orEmpty())
+ Timber.d(e)
+ PlayerItemError(e.toString())
}
playerItems.tryEmit(items)
}
}
+ fun loadOfflinePlayerItems(
+ playerItem: PlayerItem
+ ) {
+ playerItems.tryEmit(PlayerItems(listOf(playerItem)))
+ }
+
private suspend fun createItems(
item: BaseItemDto,
playbackPosition: Long,
@@ -84,8 +95,8 @@ class PlayerViewModel @Inject internal constructor(
mediaSourceIndex: Int
): List = when (item.type) {
"Movie" -> itemToMoviePlayerItems(item, playbackPosition, mediaSourceIndex)
- "Series" -> itemToPlayerItems(item, playbackPosition, mediaSourceIndex)
- "Episode" -> itemToPlayerItems(item, playbackPosition, mediaSourceIndex)
+ "Series" -> seriesToPlayerItems(item, playbackPosition, mediaSourceIndex)
+ "Episode" -> episodeToPlayerItems(item, playbackPosition, mediaSourceIndex)
else -> emptyList()
}
@@ -102,23 +113,47 @@ class PlayerViewModel @Inject internal constructor(
)
)
- private suspend fun itemToPlayerItems(
+ private suspend fun seriesToPlayerItems(
item: BaseItemDto,
playbackPosition: Long,
mediaSourceIndex: Int
): List {
- val nextUp = repository.getNextUp(item.seriesId)
+ val nextUp = repository.getNextUp(item.id)
return if (nextUp.isEmpty()) {
repository
- .getSeasons(item.seriesId!!)
- .flatMap { episodesToPlayerItems(item, playbackPosition, mediaSourceIndex) }
+ .getSeasons(item.id)
+ .flatMap { seasonToPlayerItems(it, playbackPosition, mediaSourceIndex) }
} else {
- episodesToPlayerItems(item, playbackPosition, mediaSourceIndex)
+ episodeToPlayerItems(nextUp.first(), playbackPosition, mediaSourceIndex)
}
}
- private suspend fun episodesToPlayerItems(
+ private suspend fun seasonToPlayerItems(
+ item: BaseItemDto,
+ playbackPosition: Long,
+ mediaSourceIndex: Int
+ ): List {
+ val episodes = repository.getEpisodes(
+ seriesId = item.seriesId!!,
+ seasonId = item.id,
+ fields = listOf(ItemFields.MEDIA_SOURCES)
+ )
+
+ return episodes
+ .filter { it.mediaSources != null && it.mediaSources?.isNotEmpty() == true }
+ .filter { it.locationType != VIRTUAL }
+ .map { episode ->
+ PlayerItem(
+ episode.name,
+ episode.id,
+ episode.mediaSources?.get(mediaSourceIndex)?.id!!,
+ playbackPosition
+ )
+ }
+ }
+
+ private suspend fun episodeToPlayerItems(
item: BaseItemDto,
playbackPosition: Long,
mediaSourceIndex: Int
@@ -145,6 +180,6 @@ class PlayerViewModel @Inject internal constructor(
sealed class PlayerItemState
- data class PlayerItemError(val message: String): PlayerItemState()
- data class PlayerItems(val items: List): PlayerItemState()
+ data class PlayerItemError(val message: String) : PlayerItemState()
+ data class PlayerItems(val items: List) : PlayerItemState()
}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml
new file mode 100644
index 00000000..ea8168ae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_download.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_trash.xml b/app/src/main/res/drawable/ic_trash.xml
new file mode 100644
index 00000000..7aec1513
--- /dev/null
+++ b/app/src/main/res/drawable/ic_trash.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/download_section.xml b/app/src/main/res/layout/download_section.xml
new file mode 100644
index 00000000..55fcdc86
--- /dev/null
+++ b/app/src/main/res/layout/download_section.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 ac239823..6537b1dd 100644
--- a/app/src/main/res/layout/episode_bottom_sheet.xml
+++ b/app/src/main/res/layout/episode_bottom_sheet.xml
@@ -183,10 +183,31 @@
android:id="@+id/favorite_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/favorite_button_description"
android:padding="12dp"
android:src="@drawable/ic_heart" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 06d57999..fadcd7cd 100644
--- a/app/src/main/res/layout/fragment_favorite.xml
+++ b/app/src/main/res/layout/fragment_favorite.xml
@@ -45,12 +45,14 @@
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="16dp"
+ app:favoriteSections="@{viewModel.favoriteSections}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
- app:favoriteSections="@{viewModel.favoriteSections}"
+ app:layout_constraintVertical_bias="0.0"
tools:itemCount="4"
tools:listitem="@layout/favorite_section" />
diff --git a/app/src/main/res/layout/fragment_media_info.xml b/app/src/main/res/layout/fragment_media_info.xml
index aedd5366..81625285 100644
--- a/app/src/main/res/layout/fragment_media_info.xml
+++ b/app/src/main/res/layout/fragment_media_info.xml
@@ -187,10 +187,31 @@
android:id="@+id/favorite_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
- android:contentDescription="@string/favorite_button_description"
+ android:contentDescription="@string/download_button_description"
android:padding="12dp"
android:src="@drawable/ic_heart" />
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml
index cc199d2d..d4bb31ec 100644
--- a/app/src/main/res/navigation/main_navigation.xml
+++ b/app/src/main/res/navigation/main_navigation.xml
@@ -107,6 +107,14 @@
android:defaultValue="Media Info"
app:argType="string"
app:nullable="true" />
+
+
@@ -118,11 +126,11 @@
app:destination="@id/playerActivity" />
+ app:destination="@id/personDetailFragment" />
+ android:name="isOffline"
+ app:argType="boolean"
+ android:defaultValue="false" />
+
+
+
+
+
+
My media
Favorites
Settings
+ Downloads
View all
Error loading data
Retry
@@ -38,6 +39,7 @@
Latest %1$s
Series poster
You have no favorites
+ You have nothing downloaded
Search
No search results
Language
@@ -64,6 +66,9 @@
Use the experimental MPV Player to play videos. MPV has support for more video, audio and subtitle codecs.
Force software decoding
Disable hardware decoding and use software decoding. Can be useful if hardware decoding gives weird artifacts.
+ Download
+ Cannot download files without storage permissions
+ Delete
Person Detail
Detail unavailable
Movies