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:
parent
308d97068f
commit
532e9adac1
33 changed files with 1196 additions and 80 deletions
0
.idea/gradle.properties
Normal file
0
.idea/gradle.properties
Normal 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"
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||||
|
)
|
|
@ -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
|
|
@ -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
|
||||||
|
|
247
app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt
Normal file
247
app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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) }
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
27
app/src/main/res/drawable/ic_download.xml
Normal file
27
app/src/main/res/drawable/ic_download.xml
Normal 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>
|
34
app/src/main/res/drawable/ic_trash.xml
Normal file
34
app/src/main/res/drawable/ic_trash.xml
Normal 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>
|
40
app/src/main/res/layout/download_section.xml
Normal file
40
app/src/main/res/layout/download_section.xml
Normal 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>
|
|
@ -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
|
||||||
|
|
61
app/src/main/res/layout/fragment_download.xml
Normal file
61
app/src/main/res/layout/fragment_download.xml
Normal 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>
|
|
@ -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" />
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue