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">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<application
|
||||
android:name=".BaseApplication"
|
||||
android:allowBackup="true"
|
||||
|
|
|
@ -5,18 +5,10 @@ import androidx.databinding.BindingAdapter
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||
import dev.jdtech.jellyfin.adapters.CollectionListAdapter
|
||||
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.adapters.*
|
||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||
import dev.jdtech.jellyfin.database.Server
|
||||
import dev.jdtech.jellyfin.models.DownloadSection
|
||||
import dev.jdtech.jellyfin.models.FavoriteSection
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||
|
@ -131,7 +123,7 @@ fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
|
|||
var imageItemId = episode.id
|
||||
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) {
|
||||
"Movie" -> {
|
||||
if (!episode.backdropImageTags.isNullOrEmpty()) {
|
||||
|
@ -178,3 +170,9 @@ fun bindFavoriteSections(recyclerView: RecyclerView, data: List<FavoriteSection>
|
|||
val adapter = recyclerView.adapter as FavoritesListAdapter
|
||||
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.
|
||||
val appBarConfiguration = AppBarConfiguration(
|
||||
setOf(
|
||||
R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment
|
||||
R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment, R.id.downloadFragment
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
|
@ -17,9 +18,11 @@ import dev.jdtech.jellyfin.R
|
|||
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
|
||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.utils.requestDownload
|
||||
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
@AndroidEntryPoint
|
||||
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||
|
@ -43,7 +46,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
binding.playButton.setImageResource(android.R.color.transparent)
|
||||
binding.progressCircular.visibility = View.VISIBLE
|
||||
viewModel.item.value?.let {
|
||||
if (!args.isOffline) {
|
||||
playerViewModel.loadPlayerItems(it)
|
||||
} else {
|
||||
playerViewModel.loadOfflinePlayerItems(viewModel.playerItems[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,20 +61,6 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
binding.checkButton.setOnClickListener {
|
||||
when (viewModel.played.value) {
|
||||
true -> viewModel.markAsUnplayed(args.episodeId)
|
||||
false -> viewModel.markAsPlayed(args.episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
binding.favoriteButton.setOnClickListener {
|
||||
when (viewModel.favorite.value) {
|
||||
true -> viewModel.unmarkAsFavorite(args.episodeId)
|
||||
false -> viewModel.markAsFavorite(args.episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.item.observe(viewLifecycleOwner, { episode ->
|
||||
if (episode.userData?.playedPercentage != null) {
|
||||
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
||||
|
@ -101,7 +94,50 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
binding.favoriteButton.setImageResource(drawable)
|
||||
})
|
||||
|
||||
viewModel.loadEpisode(args.episodeId)
|
||||
viewModel.downloadEpisode.observe(viewLifecycleOwner, {
|
||||
if (it) {
|
||||
requestDownload(Uri.parse(viewModel.downloadRequestItem.uri), viewModel.downloadRequestItem, this)
|
||||
viewModel.doneDownloadEpisode()
|
||||
}
|
||||
})
|
||||
|
||||
if(!args.isOffline){
|
||||
val episodeId: UUID = args.episodeId
|
||||
binding.checkButton.setOnClickListener {
|
||||
when (viewModel.played.value) {
|
||||
true -> viewModel.markAsUnplayed(episodeId)
|
||||
false -> viewModel.markAsPlayed(episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
binding.favoriteButton.setOnClickListener {
|
||||
when (viewModel.favorite.value) {
|
||||
true -> viewModel.unmarkAsFavorite(episodeId)
|
||||
false -> viewModel.markAsFavorite(episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
binding.downloadButton.setOnClickListener {
|
||||
viewModel.loadDownloadRequestItem(episodeId)
|
||||
}
|
||||
|
||||
binding.deleteButton.visibility = View.GONE
|
||||
|
||||
viewModel.loadEpisode(episodeId)
|
||||
}else {
|
||||
val playerItem = args.playerItem!!
|
||||
viewModel.loadEpisode(playerItem)
|
||||
|
||||
binding.deleteButton.setOnClickListener {
|
||||
viewModel.deleteEpisode()
|
||||
dismiss()
|
||||
findNavController().navigate(R.id.downloadFragment)
|
||||
}
|
||||
|
||||
binding.checkButton.visibility = View.GONE
|
||||
binding.favoriteButton.visibility = View.GONE
|
||||
binding.downloadButton.visibility = View.GONE
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
|||
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||
import dev.jdtech.jellyfin.utils.requestDownload
|
||||
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
|
@ -65,10 +66,21 @@ class MediaInfoFragment : Fragment() {
|
|||
}
|
||||
})
|
||||
|
||||
if(args.itemType != "Movie") {
|
||||
binding.downloadButton.visibility = View.GONE
|
||||
}
|
||||
|
||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||
viewModel.loadData(args.itemId, args.itemType)
|
||||
}
|
||||
|
||||
viewModel.downloadMedia.observe(viewLifecycleOwner, {
|
||||
if (it) {
|
||||
requestDownload(Uri.parse(viewModel.downloadRequestItem.uri), viewModel.downloadRequestItem, this)
|
||||
viewModel.doneDownloadMedia()
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.item.observe(viewLifecycleOwner, { item ->
|
||||
if (item.originalTitle != item.name) {
|
||||
binding.originalTitle.visibility = View.VISIBLE
|
||||
|
@ -82,6 +94,7 @@ class MediaInfoFragment : Fragment() {
|
|||
true -> View.VISIBLE
|
||||
false -> View.GONE
|
||||
}
|
||||
Timber.d(item.seasonId.toString())
|
||||
})
|
||||
|
||||
viewModel.actors.observe(viewLifecycleOwner, { actors ->
|
||||
|
@ -147,14 +160,23 @@ class MediaInfoFragment : Fragment() {
|
|||
binding.progressCircular.visibility = View.VISIBLE
|
||||
|
||||
viewModel.item.value?.let { item ->
|
||||
if (!args.isOffline) {
|
||||
playerViewModel.loadPlayerItems(item) {
|
||||
VideoVersionDialogFragment(item, playerViewModel).show(
|
||||
parentFragmentManager,
|
||||
"videoversiondialog"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
playerViewModel.loadOfflinePlayerItems(args.playerItem!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.isOffline) {
|
||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||
viewModel.loadData(args.itemId, args.itemType)
|
||||
}
|
||||
|
||||
binding.checkButton.setOnClickListener {
|
||||
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)
|
||||
} else {
|
||||
binding.favoriteButton.visibility = View.GONE
|
||||
binding.checkButton.visibility = View.GONE
|
||||
binding.downloadButton.visibility = View.GONE
|
||||
|
||||
binding.deleteButton.setOnClickListener {
|
||||
viewModel.deleteItem()
|
||||
findNavController().navigate(R.id.downloadFragment)
|
||||
}
|
||||
|
||||
viewModel.loadData(args.playerItem!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||
|
@ -186,7 +226,6 @@ class MediaInfoFragment : Fragment() {
|
|||
|
||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||
Timber.e(error.message)
|
||||
|
||||
binding.playerItemsError.visibility = View.VISIBLE
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
|
@ -195,7 +234,7 @@ class MediaInfoFragment : Fragment() {
|
|||
)
|
||||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
||||
binding.playerItemsErrorDetails.setOnClickListener {
|
||||
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 itemId: UUID,
|
||||
val mediaSourceId: String,
|
||||
val playbackPosition: Long
|
||||
val playbackPosition: Long,
|
||||
val mediaSourceUri: String = "",
|
||||
val metadata: DownloadMetadata? = null
|
||||
) : Parcelable
|
|
@ -32,6 +32,7 @@ import kotlinx.parcelize.Parcelize
|
|||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.lang.IllegalArgumentException
|
||||
|
|
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
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import dev.jdtech.jellyfin.MainNavigationDirections
|
||||
|
@ -28,3 +31,6 @@ fun Fragment.checkIfLoginRequired(error: String) {
|
|||
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.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.models.DownloadMetadata
|
||||
import dev.jdtech.jellyfin.models.DownloadRequestItem
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
|
||||
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
|
||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.LocationType
|
||||
import timber.log.Timber
|
||||
import java.text.DateFormat
|
||||
import java.time.ZoneOffset
|
||||
|
@ -38,6 +46,13 @@ constructor(
|
|||
private val _favorite = MutableLiveData<Boolean>()
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
|
@ -53,6 +68,11 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun loadEpisode(playerItem : PlayerItem){
|
||||
playerItems.add(playerItem)
|
||||
_item.value = downloadMetadataToBaseItemDto(playerItem.metadata!!)
|
||||
}
|
||||
|
||||
fun markAsPlayed(itemId: UUID) {
|
||||
viewModelScope.launch {
|
||||
jellyfinRepository.markAsPlayed(itemId)
|
||||
|
@ -81,6 +101,21 @@ constructor(
|
|||
_favorite.value = false
|
||||
}
|
||||
|
||||
fun loadDownloadRequestItem(itemId: UUID) {
|
||||
viewModelScope.launch {
|
||||
loadEpisode(itemId)
|
||||
val episode = _item.value
|
||||
val uri = jellyfinRepository.getStreamUrl(itemId, episode?.mediaSources?.get(0)?.id!!)
|
||||
val metadata = baseItemDtoToDownloadMetadata(episode)
|
||||
downloadRequestItem = DownloadRequestItem(uri, itemId, metadata)
|
||||
_downloadEpisode.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteEpisode() {
|
||||
deleteDownloadedEpisode(playerItems[0].mediaSourceUri)
|
||||
}
|
||||
|
||||
private fun getDateString(item: BaseItemDto): String {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val instant = item.premiereDate?.toInstant(ZoneOffset.UTC)
|
||||
|
@ -91,4 +126,8 @@ constructor(
|
|||
item.premiereDate.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun doneDownloadEpisode() {
|
||||
_downloadEpisode.value = false
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ import dev.jdtech.jellyfin.adapters.HomeItem
|
|||
import dev.jdtech.jellyfin.models.HomeSection
|
||||
import dev.jdtech.jellyfin.models.View
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import dev.jdtech.jellyfin.utils.syncPlaybackProgress
|
||||
import dev.jdtech.jellyfin.utils.toView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -24,7 +25,7 @@ import javax.inject.Inject
|
|||
class HomeViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
application: Application,
|
||||
private val application: Application,
|
||||
private val jellyfinRepository: JellyfinRepository
|
||||
) : ViewModel() {
|
||||
|
||||
|
@ -99,6 +100,10 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Default) {
|
||||
syncPlaybackProgress(jellyfinRepository, application)
|
||||
}
|
||||
|
||||
_views.value = items + views.map { HomeItem.ViewItem(it) }
|
||||
|
||||
|
||||
|
|
|
@ -6,7 +6,12 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.models.DownloadRequestItem
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
|
||||
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
|
||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -59,6 +64,13 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
|||
private val _error = MutableLiveData<String>()
|
||||
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) {
|
||||
_error.value = null
|
||||
viewModelScope.launch {
|
||||
|
@ -85,6 +97,11 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
fun loadData(playerItem: PlayerItem) {
|
||||
this.playerItem = playerItem
|
||||
_item.value = downloadMetadataToBaseItemDto(playerItem.metadata!!)
|
||||
}
|
||||
|
||||
private suspend fun getActors(item: BaseItemDto): List<BaseItemPerson>? {
|
||||
val actors: List<BaseItemPerson>?
|
||||
withContext(Dispatchers.Default) {
|
||||
|
@ -166,4 +183,23 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
|||
else -> dateString
|
||||
}
|
||||
}
|
||||
|
||||
fun loadDownloadRequestItem(itemId: UUID) {
|
||||
viewModelScope.launch {
|
||||
val downloadItem = _item.value
|
||||
val uri =
|
||||
jellyfinRepository.getStreamUrl(itemId, downloadItem?.mediaSources?.get(0)?.id!!)
|
||||
val metadata = baseItemDtoToDownloadMetadata(downloadItem)
|
||||
downloadRequestItem = DownloadRequestItem(uri, itemId, metadata)
|
||||
_downloadMedia.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem() {
|
||||
deleteDownloadedEpisode(playerItem.mediaSourceUri)
|
||||
}
|
||||
|
||||
fun doneDownloadMedia() {
|
||||
_downloadMedia.value = false
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@ import dev.jdtech.jellyfin.models.PlayerItem
|
|||
import dev.jdtech.jellyfin.mpv.MPVPlayer
|
||||
import dev.jdtech.jellyfin.mpv.TrackType
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import dev.jdtech.jellyfin.utils.postDownloadPlaybackProgress
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
|
@ -51,6 +52,7 @@ constructor(
|
|||
|
||||
val trackSelector = DefaultTrackSelector(application)
|
||||
var playWhenReady = true
|
||||
private var playFromDownloads = false
|
||||
private var currentWindow = 0
|
||||
private var playbackPosition: Long = 0
|
||||
|
||||
|
@ -60,7 +62,6 @@ constructor(
|
|||
|
||||
init {
|
||||
val useMpv = sp.getBoolean("mpv_player", false)
|
||||
|
||||
val preferredAudioLanguage = sp.getString("audio_language", null) ?: ""
|
||||
val preferredSubtitleLanguage = sp.getString("subtitle_language", null) ?: ""
|
||||
|
||||
|
@ -100,10 +101,15 @@ constructor(
|
|||
|
||||
viewModelScope.launch {
|
||||
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||
|
||||
try {
|
||||
for (item in items) {
|
||||
val streamUrl = jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
|
||||
playFromDownloads = item.mediaSourceUri.isNotEmpty()
|
||||
val streamUrl = if(!playFromDownloads){
|
||||
jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
|
||||
}else{
|
||||
item.mediaSourceUri
|
||||
}
|
||||
|
||||
Timber.d("Stream url: $streamUrl")
|
||||
val mediaItem =
|
||||
MediaItem.Builder()
|
||||
|
@ -117,7 +123,9 @@ constructor(
|
|||
}
|
||||
|
||||
player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition)
|
||||
player.prepare()
|
||||
val useMpv = sp.getBoolean("mpv_player", false)
|
||||
if(!useMpv || !playFromDownloads)
|
||||
player.prepare() //TODO: This line causes a crash when playing from downloads with MPV
|
||||
player.play()
|
||||
pollPosition(player)
|
||||
}
|
||||
|
@ -159,6 +167,9 @@ constructor(
|
|||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
if(playFromDownloads){
|
||||
postDownloadPlaybackProgress(items[0].mediaSourceUri, player.currentPosition, (player.currentPosition.toDouble()/player.duration.toDouble()).times(100)) //TODO Automaticcaly use the correct item
|
||||
}
|
||||
}
|
||||
}
|
||||
handler.postDelayed(this, 2000)
|
||||
|
|
|
@ -21,7 +21,11 @@ class PlayerViewModel @Inject internal constructor(
|
|||
private val repository: JellyfinRepository
|
||||
) : 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) {
|
||||
scope.launch { playerItems.collect { collector(it) } }
|
||||
|
@ -43,13 +47,20 @@ class PlayerViewModel @Inject internal constructor(
|
|||
val items = try {
|
||||
createItems(item, playbackPosition, mediaSourceIndex).let(::PlayerItems)
|
||||
} catch (e: Exception) {
|
||||
PlayerItemError(e.message.orEmpty())
|
||||
Timber.d(e)
|
||||
PlayerItemError(e.toString())
|
||||
}
|
||||
|
||||
playerItems.tryEmit(items)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadOfflinePlayerItems(
|
||||
playerItem: PlayerItem
|
||||
) {
|
||||
playerItems.tryEmit(PlayerItems(listOf(playerItem)))
|
||||
}
|
||||
|
||||
private suspend fun createItems(
|
||||
item: BaseItemDto,
|
||||
playbackPosition: Long,
|
||||
|
@ -84,8 +95,8 @@ class PlayerViewModel @Inject internal constructor(
|
|||
mediaSourceIndex: Int
|
||||
): List<PlayerItem> = when (item.type) {
|
||||
"Movie" -> itemToMoviePlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
"Series" -> itemToPlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
"Episode" -> itemToPlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
"Series" -> seriesToPlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
"Episode" -> episodeToPlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
|
@ -102,23 +113,47 @@ class PlayerViewModel @Inject internal constructor(
|
|||
)
|
||||
)
|
||||
|
||||
private suspend fun itemToPlayerItems(
|
||||
private suspend fun seriesToPlayerItems(
|
||||
item: BaseItemDto,
|
||||
playbackPosition: Long,
|
||||
mediaSourceIndex: Int
|
||||
): List<PlayerItem> {
|
||||
val nextUp = repository.getNextUp(item.seriesId)
|
||||
val nextUp = repository.getNextUp(item.id)
|
||||
|
||||
return if (nextUp.isEmpty()) {
|
||||
repository
|
||||
.getSeasons(item.seriesId!!)
|
||||
.flatMap { episodesToPlayerItems(item, playbackPosition, mediaSourceIndex) }
|
||||
.getSeasons(item.id)
|
||||
.flatMap { seasonToPlayerItems(it, playbackPosition, mediaSourceIndex) }
|
||||
} else {
|
||||
episodesToPlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
episodeToPlayerItems(nextUp.first(), playbackPosition, mediaSourceIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun episodesToPlayerItems(
|
||||
private suspend fun seasonToPlayerItems(
|
||||
item: BaseItemDto,
|
||||
playbackPosition: Long,
|
||||
mediaSourceIndex: Int
|
||||
): List<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,
|
||||
playbackPosition: Long,
|
||||
mediaSourceIndex: Int
|
||||
|
|
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:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="@drawable/button_accent_background"
|
||||
android:contentDescription="@string/favorite_button_description"
|
||||
android:padding="12dp"
|
||||
android:src="@drawable/ic_heart" />
|
||||
|
||||
<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
|
||||
|
|
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:clipToPadding="false"
|
||||
android:paddingTop="16dp"
|
||||
app:favoriteSections="@{viewModel.favoriteSections}"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:favoriteSections="@{viewModel.favoriteSections}"
|
||||
app:layout_constraintVertical_bias="0.0"
|
||||
tools:itemCount="4"
|
||||
tools:listitem="@layout/favorite_section" />
|
||||
|
||||
|
|
|
@ -187,10 +187,31 @@
|
|||
android:id="@+id/favorite_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="@drawable/button_accent_background"
|
||||
android:contentDescription="@string/favorite_button_description"
|
||||
android:contentDescription="@string/download_button_description"
|
||||
android:padding="12dp"
|
||||
android:src="@drawable/ic_heart" />
|
||||
|
||||
<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
|
||||
|
|
|
@ -16,4 +16,9 @@
|
|||
android:icon="@drawable/ic_heart"
|
||||
android:title="@string/title_favorite" />
|
||||
|
||||
<item
|
||||
android:id="@+id/downloadFragment"
|
||||
android:icon="@drawable/ic_download"
|
||||
android:title="@string/title_download" />
|
||||
|
||||
</menu>
|
|
@ -107,6 +107,14 @@
|
|||
android:defaultValue="Media Info"
|
||||
app:argType="string"
|
||||
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
|
||||
android:id="@+id/action_mediaInfoFragment_to_seasonFragment"
|
||||
app:destination="@id/seasonFragment" />
|
||||
|
@ -118,11 +126,11 @@
|
|||
app:destination="@id/playerActivity" />
|
||||
<action
|
||||
android:id="@+id/action_mediaInfoFragment_to_personDetailFragment"
|
||||
app:destination="@id/personDetailFragment"
|
||||
/>
|
||||
app:destination="@id/personDetailFragment" />
|
||||
<argument
|
||||
android:name="itemType"
|
||||
app:argType="string" />
|
||||
android:name="isOffline"
|
||||
app:argType="boolean"
|
||||
android:defaultValue="false" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/seasonFragment"
|
||||
|
@ -157,9 +165,18 @@
|
|||
<argument
|
||||
android:name="episodeId"
|
||||
app:argType="java.util.UUID" />
|
||||
<argument
|
||||
android:name="playerItem"
|
||||
android:defaultValue="@null"
|
||||
app:argType="dev.jdtech.jellyfin.models.PlayerItem"
|
||||
app:nullable="true" />
|
||||
<action
|
||||
android:id="@+id/action_episodeBottomSheetFragment_to_playerActivity"
|
||||
app:destination="@id/playerActivity" />
|
||||
<argument
|
||||
android:name="isOffline"
|
||||
app:argType="boolean"
|
||||
android:defaultValue="false" />
|
||||
</dialog>
|
||||
<activity
|
||||
android:id="@+id/playerActivity"
|
||||
|
@ -182,6 +199,18 @@
|
|||
android:id="@+id/action_favoriteFragment_to_mediaInfoFragment"
|
||||
app:destination="@id/mediaInfoFragment" />
|
||||
</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
|
||||
android:id="@+id/searchResultFragment"
|
||||
android:name="dev.jdtech.jellyfin.fragments.SearchResultFragment"
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
<string name="title_media">My media</string>
|
||||
<string name="title_favorite">Favorites</string>
|
||||
<string name="title_settings">Settings</string>
|
||||
<string name="title_download">Downloads</string>
|
||||
<string name="view_all">View all</string>
|
||||
<string name="error_loading_data">Error loading data</string>
|
||||
<string name="retry">Retry</string>
|
||||
|
@ -38,6 +39,7 @@
|
|||
<string name="latest_library">Latest %1$s</string>
|
||||
<string name="series_poster">Series poster</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="no_search_results">No search results</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="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="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="error_getting_person_id">Detail unavailable</string>
|
||||
<string name="movies_label">Movies</string>
|
||||
|
|
Loading…
Reference in a new issue