Add offline playback (#51)

* Add offline playback

* Remove unused values

* Replace downloadutilities extension functions with normal functions

This is to not polute the namespace of fragment and context.

* Replace default Android icons with those from lucide

* Fix deleting downloaded movie

Co-authored-by: Jarne Demeulemeester <32322857+jarnedemeulemeester@users.noreply.github.com>
This commit is contained in:
Jcuhfehl 2021-10-29 19:11:01 +00:00 committed by GitHub
parent 308d97068f
commit 532e9adac1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1196 additions and 80 deletions

0
.idea/gradle.properties Normal file
View file

View file

@ -3,7 +3,8 @@
package="dev.jdtech.jellyfin"> package="dev.jdtech.jellyfin">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application <application
android:name=".BaseApplication" android:name=".BaseApplication"
android:allowBackup="true" android:allowBackup="true"

View file

@ -5,18 +5,10 @@ import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import dev.jdtech.jellyfin.adapters.CollectionListAdapter import dev.jdtech.jellyfin.adapters.*
import dev.jdtech.jellyfin.adapters.EpisodeItem
import dev.jdtech.jellyfin.adapters.EpisodeListAdapter
import dev.jdtech.jellyfin.adapters.FavoritesListAdapter
import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ServerGridAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.adapters.ViewListAdapter
import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.models.DownloadSection
import dev.jdtech.jellyfin.models.FavoriteSection import dev.jdtech.jellyfin.models.FavoriteSection
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.BaseItemPerson
@ -131,7 +123,7 @@ fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
var imageItemId = episode.id var imageItemId = episode.id
var imageType = ImageType.PRIMARY var imageType = ImageType.PRIMARY
if (!episode.imageTags.isNullOrEmpty()) { if (!episode.imageTags.isNullOrEmpty()) { //TODO: Downloadmetadata currently does not store imagetags, so it always uses the backdrop
when (episode.type) { when (episode.type) {
"Movie" -> { "Movie" -> {
if (!episode.backdropImageTags.isNullOrEmpty()) { if (!episode.backdropImageTags.isNullOrEmpty()) {
@ -178,3 +170,9 @@ fun bindFavoriteSections(recyclerView: RecyclerView, data: List<FavoriteSection>
val adapter = recyclerView.adapter as FavoritesListAdapter val adapter = recyclerView.adapter as FavoritesListAdapter
adapter.submitList(data) adapter.submitList(data)
} }
@BindingAdapter("downloadSections")
fun bindDownloadSections(recyclerView: RecyclerView, data: List<DownloadSection>?) {
val adapter = recyclerView.adapter as DownloadsListAdapter
adapter.submitList(data)
}

View file

@ -42,7 +42,7 @@ class MainActivity : AppCompatActivity() {
// menu should be considered as top level destinations. // menu should be considered as top level destinations.
val appBarConfiguration = AppBarConfiguration( val appBarConfiguration = AppBarConfiguration(
setOf( setOf(
R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment, R.id.downloadFragment
) )
) )

View file

@ -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<PlayerItem, DownloadEpisodeListAdapter.EpisodeViewHolder>(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<PlayerItem>() {
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)
}
}

View file

@ -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<PlayerItem, DownloadViewItemListAdapter.ItemViewHolder>(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<PlayerItem>() {
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)
}
}

View file

@ -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<DownloadSection, DownloadsListAdapter.SectionViewHolder>(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<DownloadSection>() {
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)
}
}

View file

@ -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
)
)
}
}

View file

@ -1,5 +1,6 @@
package dev.jdtech.jellyfin.fragments package dev.jdtech.jellyfin.fragments
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
@ -17,9 +18,11 @@ import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.requestDownload
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import timber.log.Timber import timber.log.Timber
import java.util.*
@AndroidEntryPoint @AndroidEntryPoint
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
@ -43,7 +46,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.playButton.setImageResource(android.R.color.transparent) binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE binding.progressCircular.visibility = View.VISIBLE
viewModel.item.value?.let { viewModel.item.value?.let {
if (!args.isOffline) {
playerViewModel.loadPlayerItems(it) 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 -> viewModel.item.observe(viewLifecycleOwner, { episode ->
if (episode.userData?.playedPercentage != null) { if (episode.userData?.playedPercentage != null) {
binding.progressBar.layoutParams.width = TypedValue.applyDimension( binding.progressBar.layoutParams.width = TypedValue.applyDimension(
@ -101,7 +94,50 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.favoriteButton.setImageResource(drawable) 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 return binding.root
} }

View file

@ -22,6 +22,7 @@ import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
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.utils.requestDownload
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import org.jellyfin.sdk.model.api.BaseItemDto 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 { binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.itemId, args.itemType) 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 -> viewModel.item.observe(viewLifecycleOwner, { item ->
if (item.originalTitle != item.name) { if (item.originalTitle != item.name) {
binding.originalTitle.visibility = View.VISIBLE binding.originalTitle.visibility = View.VISIBLE
@ -82,6 +94,7 @@ class MediaInfoFragment : Fragment() {
true -> View.VISIBLE true -> View.VISIBLE
false -> View.GONE false -> View.GONE
} }
Timber.d(item.seasonId.toString())
}) })
viewModel.actors.observe(viewLifecycleOwner, { actors -> viewModel.actors.observe(viewLifecycleOwner, { actors ->
@ -147,14 +160,23 @@ class MediaInfoFragment : Fragment() {
binding.progressCircular.visibility = View.VISIBLE binding.progressCircular.visibility = View.VISIBLE
viewModel.item.value?.let { item -> viewModel.item.value?.let { item ->
if (!args.isOffline) {
playerViewModel.loadPlayerItems(item) { playerViewModel.loadPlayerItems(item) {
VideoVersionDialogFragment(item, playerViewModel).show( VideoVersionDialogFragment(item, playerViewModel).show(
parentFragmentManager, parentFragmentManager,
"videoversiondialog" "videoversiondialog"
) )
} }
} else {
playerViewModel.loadOfflinePlayerItems(args.playerItem!!)
} }
} }
}
if (!args.isOffline) {
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.itemId, args.itemType)
}
binding.checkButton.setOnClickListener { binding.checkButton.setOnClickListener {
when (viewModel.played.value) { when (viewModel.played.value) {
@ -170,7 +192,25 @@ class MediaInfoFragment : Fragment() {
} }
} }
binding.downloadButton.setOnClickListener {
viewModel.loadDownloadRequestItem(args.itemId)
}
binding.deleteButton.visibility = View.GONE
viewModel.loadData(args.itemId, args.itemType) 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) { private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
@ -186,7 +226,6 @@ class MediaInfoFragment : Fragment() {
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) { private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
Timber.e(error.message) Timber.e(error.message)
binding.playerItemsError.visibility = View.VISIBLE binding.playerItemsError.visibility = View.VISIBLE
binding.playButton.setImageDrawable( binding.playButton.setImageDrawable(
ContextCompat.getDrawable( ContextCompat.getDrawable(
@ -195,7 +234,7 @@ class MediaInfoFragment : Fragment() {
) )
) )
binding.progressCircular.visibility = View.INVISIBLE binding.progressCircular.visibility = View.INVISIBLE
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog") ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
} }
} }

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,9 @@
package dev.jdtech.jellyfin.models
import java.util.*
data class DownloadSection(
val id: UUID,
val name: String,
var items: List<PlayerItem>
)

View file

@ -9,5 +9,7 @@ data class PlayerItem(
val name: String?, val name: String?,
val itemId: UUID, val itemId: UUID,
val mediaSourceId: String, val mediaSourceId: String,
val playbackPosition: Long val playbackPosition: Long,
val mediaSourceUri: String = "",
val metadata: DownloadMetadata? = null
) : Parcelable ) : Parcelable

View file

@ -32,6 +32,7 @@ import kotlinx.parcelize.Parcelize
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.lang.IllegalArgumentException import java.lang.IllegalArgumentException

View file

@ -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<DownloadManager>()?.enqueue(request)
}
private fun getDownloadLocation(context: Context): File? {
return context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
}
fun loadDownloadedEpisodes(context: Context): List<PlayerItem> {
val items = mutableListOf<PlayerItem>()
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<String>) : 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)
}
}
}

View file

@ -1,5 +1,8 @@
package dev.jdtech.jellyfin.utils package dev.jdtech.jellyfin.utils
import android.content.Context
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dev.jdtech.jellyfin.MainNavigationDirections import dev.jdtech.jellyfin.MainNavigationDirections
@ -28,3 +31,6 @@ fun Fragment.checkIfLoginRequired(error: String) {
findNavController().navigate(MainNavigationDirections.actionGlobalLoginFragment()) findNavController().navigate(MainNavigationDirections.actionGlobalLoginFragment())
} }
} }
inline fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) =
Toast.makeText(this, text, duration).show()

View file

@ -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<List<DownloadSection>>()
val downloadSections: LiveData<List<DownloadSection>> = _downloadSections
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _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<DownloadSection>()
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
}
}
}

View file

@ -6,9 +6,17 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.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 kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto 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 timber.log.Timber
import java.text.DateFormat import java.text.DateFormat
import java.time.ZoneOffset import java.time.ZoneOffset
@ -38,6 +46,13 @@ constructor(
private val _favorite = MutableLiveData<Boolean>() private val _favorite = MutableLiveData<Boolean>()
val favorite: LiveData<Boolean> = _favorite val favorite: LiveData<Boolean> = _favorite
private val _downloadEpisode = MutableLiveData<Boolean>()
val downloadEpisode: LiveData<Boolean> = _downloadEpisode
var playerItems: MutableList<PlayerItem> = mutableListOf()
lateinit var downloadRequestItem: DownloadRequestItem
fun loadEpisode(episodeId: UUID) { fun loadEpisode(episodeId: UUID) {
viewModelScope.launch { viewModelScope.launch {
try { try {
@ -53,6 +68,11 @@ constructor(
} }
} }
fun loadEpisode(playerItem : PlayerItem){
playerItems.add(playerItem)
_item.value = downloadMetadataToBaseItemDto(playerItem.metadata!!)
}
fun markAsPlayed(itemId: UUID) { fun markAsPlayed(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.markAsPlayed(itemId) jellyfinRepository.markAsPlayed(itemId)
@ -81,6 +101,21 @@ constructor(
_favorite.value = false _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 { private fun getDateString(item: BaseItemDto): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val instant = item.premiereDate?.toInstant(ZoneOffset.UTC) val instant = item.premiereDate?.toInstant(ZoneOffset.UTC)
@ -91,4 +126,8 @@ constructor(
item.premiereDate.toString() item.premiereDate.toString()
} }
} }
fun doneDownloadEpisode() {
_downloadEpisode.value = false
}
} }

View file

@ -11,6 +11,7 @@ import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.models.HomeSection import dev.jdtech.jellyfin.models.HomeSection
import dev.jdtech.jellyfin.models.View import dev.jdtech.jellyfin.models.View
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.syncPlaybackProgress
import dev.jdtech.jellyfin.utils.toView import dev.jdtech.jellyfin.utils.toView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -24,7 +25,7 @@ import javax.inject.Inject
class HomeViewModel class HomeViewModel
@Inject @Inject
constructor( constructor(
application: Application, private val application: Application,
private val jellyfinRepository: JellyfinRepository private val jellyfinRepository: JellyfinRepository
) : ViewModel() { ) : ViewModel() {
@ -99,6 +100,10 @@ constructor(
} }
} }
withContext(Dispatchers.Default) {
syncPlaybackProgress(jellyfinRepository, application)
}
_views.value = items + views.map { HomeItem.ViewItem(it) } _views.value = items + views.map { HomeItem.ViewItem(it) }

View file

@ -6,7 +6,12 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -59,6 +64,13 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
private val _error = MutableLiveData<String>() private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error val error: LiveData<String> = _error
private val _downloadMedia = MutableLiveData<Boolean>()
val downloadMedia: LiveData<Boolean> = _downloadMedia
lateinit var downloadRequestItem: DownloadRequestItem
lateinit var playerItem: PlayerItem
fun loadData(itemId: UUID, itemType: String) { fun loadData(itemId: UUID, itemType: String) {
_error.value = null _error.value = null
viewModelScope.launch { 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<BaseItemPerson>? { private suspend fun getActors(item: BaseItemDto): List<BaseItemPerson>? {
val actors: List<BaseItemPerson>? val actors: List<BaseItemPerson>?
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@ -166,4 +183,23 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
else -> dateString 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
}
} }

View file

@ -20,6 +20,7 @@ import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.mpv.MPVPlayer import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.postDownloadPlaybackProgress
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import timber.log.Timber import timber.log.Timber
@ -51,6 +52,7 @@ constructor(
val trackSelector = DefaultTrackSelector(application) val trackSelector = DefaultTrackSelector(application)
var playWhenReady = true var playWhenReady = true
private var playFromDownloads = false
private var currentWindow = 0 private var currentWindow = 0
private var playbackPosition: Long = 0 private var playbackPosition: Long = 0
@ -60,7 +62,6 @@ constructor(
init { init {
val useMpv = sp.getBoolean("mpv_player", false) val useMpv = sp.getBoolean("mpv_player", false)
val preferredAudioLanguage = sp.getString("audio_language", null) ?: "" val preferredAudioLanguage = sp.getString("audio_language", null) ?: ""
val preferredSubtitleLanguage = sp.getString("subtitle_language", null) ?: "" val preferredSubtitleLanguage = sp.getString("subtitle_language", null) ?: ""
@ -100,10 +101,15 @@ constructor(
viewModelScope.launch { viewModelScope.launch {
val mediaItems: MutableList<MediaItem> = mutableListOf() val mediaItems: MutableList<MediaItem> = mutableListOf()
try { try {
for (item in items) { 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") Timber.d("Stream url: $streamUrl")
val mediaItem = val mediaItem =
MediaItem.Builder() MediaItem.Builder()
@ -117,7 +123,9 @@ constructor(
} }
player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition) 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() player.play()
pollPosition(player) pollPosition(player)
} }
@ -159,6 +167,9 @@ constructor(
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) 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
}
} }
} }
handler.postDelayed(this, 2000) handler.postDelayed(this, 2000)

View file

@ -21,7 +21,11 @@ class PlayerViewModel @Inject internal constructor(
private val repository: JellyfinRepository private val repository: JellyfinRepository
) : ViewModel() { ) : ViewModel() {
private val playerItems = MutableSharedFlow<PlayerItemState>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val playerItems = MutableSharedFlow<PlayerItemState>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
fun onPlaybackRequested(scope: LifecycleCoroutineScope, collector: (PlayerItemState) -> Unit) { fun onPlaybackRequested(scope: LifecycleCoroutineScope, collector: (PlayerItemState) -> Unit) {
scope.launch { playerItems.collect { collector(it) } } scope.launch { playerItems.collect { collector(it) } }
@ -43,13 +47,20 @@ class PlayerViewModel @Inject internal constructor(
val items = try { val items = try {
createItems(item, playbackPosition, mediaSourceIndex).let(::PlayerItems) createItems(item, playbackPosition, mediaSourceIndex).let(::PlayerItems)
} catch (e: Exception) { } catch (e: Exception) {
PlayerItemError(e.message.orEmpty()) Timber.d(e)
PlayerItemError(e.toString())
} }
playerItems.tryEmit(items) playerItems.tryEmit(items)
} }
} }
fun loadOfflinePlayerItems(
playerItem: PlayerItem
) {
playerItems.tryEmit(PlayerItems(listOf(playerItem)))
}
private suspend fun createItems( private suspend fun createItems(
item: BaseItemDto, item: BaseItemDto,
playbackPosition: Long, playbackPosition: Long,
@ -84,8 +95,8 @@ class PlayerViewModel @Inject internal constructor(
mediaSourceIndex: Int mediaSourceIndex: Int
): List<PlayerItem> = when (item.type) { ): List<PlayerItem> = when (item.type) {
"Movie" -> itemToMoviePlayerItems(item, playbackPosition, mediaSourceIndex) "Movie" -> itemToMoviePlayerItems(item, playbackPosition, mediaSourceIndex)
"Series" -> itemToPlayerItems(item, playbackPosition, mediaSourceIndex) "Series" -> seriesToPlayerItems(item, playbackPosition, mediaSourceIndex)
"Episode" -> itemToPlayerItems(item, playbackPosition, mediaSourceIndex) "Episode" -> episodeToPlayerItems(item, playbackPosition, mediaSourceIndex)
else -> emptyList() else -> emptyList()
} }
@ -102,23 +113,47 @@ class PlayerViewModel @Inject internal constructor(
) )
) )
private suspend fun itemToPlayerItems( private suspend fun seriesToPlayerItems(
item: BaseItemDto, item: BaseItemDto,
playbackPosition: Long, playbackPosition: Long,
mediaSourceIndex: Int mediaSourceIndex: Int
): List<PlayerItem> { ): List<PlayerItem> {
val nextUp = repository.getNextUp(item.seriesId) val nextUp = repository.getNextUp(item.id)
return if (nextUp.isEmpty()) { return if (nextUp.isEmpty()) {
repository repository
.getSeasons(item.seriesId!!) .getSeasons(item.id)
.flatMap { episodesToPlayerItems(item, playbackPosition, mediaSourceIndex) } .flatMap { seasonToPlayerItems(it, playbackPosition, mediaSourceIndex) }
} else { } 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<PlayerItem> {
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, item: BaseItemDto,
playbackPosition: Long, playbackPosition: Long,
mediaSourceIndex: Int mediaSourceIndex: Int
@ -145,6 +180,6 @@ class PlayerViewModel @Inject internal constructor(
sealed class PlayerItemState sealed class PlayerItemState
data class PlayerItemError(val message: String): PlayerItemState() data class PlayerItemError(val message: String) : PlayerItemState()
data class PlayerItems(val items: List<PlayerItem>): PlayerItemState() data class PlayerItems(val items: List<PlayerItem>) : PlayerItemState()
} }

View file

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M21,15v4a2,2 0,0 1,-2 2H5a2,2 0,0 1,-2 -2v-4"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M7,10l5,5l5,-5"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M12,15L12,3"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,6l2,0l16,0"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M19,6v14a2,2 0,0 1,-2 2H7a2,2 0,0 1,-2 -2V6m3,0V4a2,2 0,0 1,2 -2h4a2,2 0,0 1,2 2v2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M10,11L10,17"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M14,11L14,17"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,40 @@
<?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">
<data>
<variable
name="section"
type="dev.jdtech.jellyfin.models.DownloadSection" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/section_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginBottom="12dp"
android:text="@{section.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp"
tools:text="Movies" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/items_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/home_episode_item" />
</LinearLayout>
</layout>

View file

@ -183,10 +183,31 @@
android:id="@+id/favorite_button" android:id="@+id/favorite_button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background" android:background="@drawable/button_accent_background"
android:contentDescription="@string/favorite_button_description" android:contentDescription="@string/favorite_button_description"
android:padding="12dp" android:padding="12dp"
android:src="@drawable/ic_heart" /> android:src="@drawable/ic_heart" />
<ImageButton
android:id="@+id/download_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/download_button_description"
android:padding="12dp"
android:src="@drawable/ic_download" />
<ImageButton
android:id="@+id/delete_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/delete_button_description"
android:padding="12dp"
android:src="@drawable/ic_trash" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout

View file

@ -0,0 +1,61 @@
<?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">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.DownloadViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:trackCornerRadius="10dp" />
<include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
<TextView
android:id="@+id/no_downloads_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_downloads"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:visibility="gone"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/downloads_recycler_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="16dp"
app:downloadSections="@{viewModel.downloadSections}"
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:layout_constraintVertical_bias="0.0"
tools:itemCount="4"
tools:listitem="@layout/download_section" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -45,12 +45,14 @@
android:layout_height="0dp" android:layout_height="0dp"
android:clipToPadding="false" android:clipToPadding="false"
android:paddingTop="16dp" android:paddingTop="16dp"
app:favoriteSections="@{viewModel.favoriteSections}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:favoriteSections="@{viewModel.favoriteSections}" app:layout_constraintVertical_bias="0.0"
tools:itemCount="4" tools:itemCount="4"
tools:listitem="@layout/favorite_section" /> tools:listitem="@layout/favorite_section" />

View file

@ -187,10 +187,31 @@
android:id="@+id/favorite_button" android:id="@+id/favorite_button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background" android:background="@drawable/button_accent_background"
android:contentDescription="@string/favorite_button_description" android:contentDescription="@string/download_button_description"
android:padding="12dp" android:padding="12dp"
android:src="@drawable/ic_heart" /> android:src="@drawable/ic_heart" />
<ImageButton
android:id="@+id/download_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/download_button_description"
android:padding="12dp"
android:src="@drawable/ic_download" />
<ImageButton
android:id="@+id/delete_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/delete_button_description"
android:padding="12dp"
android:src="@drawable/ic_trash" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout

View file

@ -16,4 +16,9 @@
android:icon="@drawable/ic_heart" android:icon="@drawable/ic_heart"
android:title="@string/title_favorite" /> android:title="@string/title_favorite" />
<item
android:id="@+id/downloadFragment"
android:icon="@drawable/ic_download"
android:title="@string/title_download" />
</menu> </menu>

View file

@ -107,6 +107,14 @@
android:defaultValue="Media Info" android:defaultValue="Media Info"
app:argType="string" app:argType="string"
app:nullable="true" /> app:nullable="true" />
<argument
android:name="itemType"
app:argType="string" />
<argument
android:name="playerItem"
android:defaultValue="@null"
app:argType="dev.jdtech.jellyfin.models.PlayerItem"
app:nullable="true" />
<action <action
android:id="@+id/action_mediaInfoFragment_to_seasonFragment" android:id="@+id/action_mediaInfoFragment_to_seasonFragment"
app:destination="@id/seasonFragment" /> app:destination="@id/seasonFragment" />
@ -118,11 +126,11 @@
app:destination="@id/playerActivity" /> app:destination="@id/playerActivity" />
<action <action
android:id="@+id/action_mediaInfoFragment_to_personDetailFragment" android:id="@+id/action_mediaInfoFragment_to_personDetailFragment"
app:destination="@id/personDetailFragment" app:destination="@id/personDetailFragment" />
/>
<argument <argument
android:name="itemType" android:name="isOffline"
app:argType="string" /> app:argType="boolean"
android:defaultValue="false" />
</fragment> </fragment>
<fragment <fragment
android:id="@+id/seasonFragment" android:id="@+id/seasonFragment"
@ -157,9 +165,18 @@
<argument <argument
android:name="episodeId" android:name="episodeId"
app:argType="java.util.UUID" /> app:argType="java.util.UUID" />
<argument
android:name="playerItem"
android:defaultValue="@null"
app:argType="dev.jdtech.jellyfin.models.PlayerItem"
app:nullable="true" />
<action <action
android:id="@+id/action_episodeBottomSheetFragment_to_playerActivity" android:id="@+id/action_episodeBottomSheetFragment_to_playerActivity"
app:destination="@id/playerActivity" /> app:destination="@id/playerActivity" />
<argument
android:name="isOffline"
app:argType="boolean"
android:defaultValue="false" />
</dialog> </dialog>
<activity <activity
android:id="@+id/playerActivity" android:id="@+id/playerActivity"
@ -182,6 +199,18 @@
android:id="@+id/action_favoriteFragment_to_mediaInfoFragment" android:id="@+id/action_favoriteFragment_to_mediaInfoFragment"
app:destination="@id/mediaInfoFragment" /> app:destination="@id/mediaInfoFragment" />
</fragment> </fragment>
<fragment
android:id="@+id/downloadFragment"
android:name="dev.jdtech.jellyfin.fragments.DownloadFragment"
android:label="@string/title_download"
tools:layout="@layout/fragment_download">
<action
android:id="@+id/action_downloadFragment_to_episodeBottomSheetFragment"
app:destination="@id/episodeBottomSheetFragment" />
<action
android:id="@+id/action_downloadFragment_to_mediaInfoFragment"
app:destination="@id/mediaInfoFragment" />
</fragment>
<fragment <fragment
android:id="@+id/searchResultFragment" android:id="@+id/searchResultFragment"
android:name="dev.jdtech.jellyfin.fragments.SearchResultFragment" android:name="dev.jdtech.jellyfin.fragments.SearchResultFragment"

View file

@ -18,6 +18,7 @@
<string name="title_media">My media</string> <string name="title_media">My media</string>
<string name="title_favorite">Favorites</string> <string name="title_favorite">Favorites</string>
<string name="title_settings">Settings</string> <string name="title_settings">Settings</string>
<string name="title_download">Downloads</string>
<string name="view_all">View all</string> <string name="view_all">View all</string>
<string name="error_loading_data">Error loading data</string> <string name="error_loading_data">Error loading data</string>
<string name="retry">Retry</string> <string name="retry">Retry</string>
@ -38,6 +39,7 @@
<string name="latest_library">Latest %1$s</string> <string name="latest_library">Latest %1$s</string>
<string name="series_poster">Series poster</string> <string name="series_poster">Series poster</string>
<string name="no_favorites">You have no favorites</string> <string name="no_favorites">You have no favorites</string>
<string name="no_downloads">You have nothing downloaded</string>
<string name="search">Search</string> <string name="search">Search</string>
<string name="no_search_results">No search results</string> <string name="no_search_results">No search results</string>
<string name="settings_category_language">Language</string> <string name="settings_category_language">Language</string>
@ -64,6 +66,9 @@
<string name="mpv_player_summary">Use the experimental MPV Player to play videos. MPV has support for more video, audio and subtitle codecs.</string> <string name="mpv_player_summary">Use the experimental MPV Player to play videos. MPV has support for more video, audio and subtitle codecs.</string>
<string name="force_software_decoding">Force software decoding</string> <string name="force_software_decoding">Force software decoding</string>
<string name="force_software_decoding_summary">Disable hardware decoding and use software decoding. Can be useful if hardware decoding gives weird artifacts.</string> <string name="force_software_decoding_summary">Disable hardware decoding and use software decoding. Can be useful if hardware decoding gives weird artifacts.</string>
<string name="download_button_description">Download</string>
<string name="download_no_storage_permission">Cannot download files without storage permissions</string>
<string name="delete_button_description">Delete</string>
<string name="person_detail_title">Person Detail</string> <string name="person_detail_title">Person Detail</string>
<string name="error_getting_person_id">Detail unavailable</string> <string name="error_getting_person_id">Detail unavailable</string>
<string name="movies_label">Movies</string> <string name="movies_label">Movies</string>