Add offline playback (#51)

* Add offline playback

* Remove unused values

* Replace downloadutilities extension functions with normal functions

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

* Replace default Android icons with those from lucide

* Fix deleting downloaded movie

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

0
.idea/gradle.properties Normal file
View file

View file

@ -3,7 +3,8 @@
package="dev.jdtech.jellyfin">
<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"

View file

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

View file

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

View file

@ -0,0 +1,68 @@
package dev.jdtech.jellyfin.adapters
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
import timber.log.Timber
class DownloadEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter<PlayerItem, DownloadEpisodeListAdapter.EpisodeViewHolder>(DiffCallback) {
class EpisodeViewHolder(private var binding: HomeEpisodeItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(episode: PlayerItem) {
val metadata = episode.metadata!!
binding.episode = downloadMetadataToBaseItemDto(episode.metadata)
if (metadata.playedPercentage != null) {
binding.progressBar.layoutParams.width = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
(metadata.playedPercentage.times(2.24)).toFloat(), binding.progressBar.context.resources.displayMetrics).toInt()
binding.progressBar.visibility = View.VISIBLE
}
if (metadata.type == "Movie") {
binding.primaryName.text = metadata.name
Timber.d(metadata.name)
binding.secondaryName.visibility = View.GONE
} else if (metadata.type == "Episode") {
binding.primaryName.text = metadata.seriesName
}
binding.executePendingBindings()
}
}
companion object DiffCallback : DiffUtil.ItemCallback<PlayerItem>() {
override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
return oldItem.itemId == newItem.itemId
}
override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
return oldItem == newItem
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder {
return EpisodeViewHolder(
HomeEpisodeItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) {
val item = getItem(position)
holder.itemView.setOnClickListener {
onClickListener.onClick(item)
}
holder.bind(item)
}
class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) {
fun onClick(item: PlayerItem) = clickListener(item)
}
}

View file

@ -0,0 +1,67 @@
package dev.jdtech.jellyfin.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.databinding.BaseItemBinding
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
class DownloadViewItemListAdapter(
private val onClickListener: OnClickListener,
private val fixedWidth: Boolean = false,
) :
ListAdapter<PlayerItem, DownloadViewItemListAdapter.ItemViewHolder>(DiffCallback) {
class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: PlayerItem, fixedWidth: Boolean) {
val metadata = item.metadata!!
binding.item = downloadMetadataToBaseItemDto(metadata)
binding.itemName.text = if (metadata.type == "Episode") metadata.seriesName else item.name
binding.itemCount.visibility = View.GONE
if (fixedWidth) {
binding.itemLayout.layoutParams.width =
parent.resources.getDimension(R.dimen.overview_media_width).toInt()
(binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
}
binding.executePendingBindings()
}
}
companion object DiffCallback : DiffUtil.ItemCallback<PlayerItem>() {
override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
return oldItem.itemId == newItem.itemId
}
override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
return oldItem == newItem
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
return ItemViewHolder(
BaseItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
), parent
)
}
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val item = getItem(position)
holder.itemView.setOnClickListener {
onClickListener.onClick(item)
}
holder.bind(item, fixedWidth)
}
class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) {
fun onClick(item: PlayerItem) = clickListener(item)
}
}

View file

@ -0,0 +1,63 @@
package dev.jdtech.jellyfin.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.databinding.DownloadSectionBinding
import dev.jdtech.jellyfin.models.DownloadSection
class DownloadsListAdapter(
private val onClickListener: DownloadViewItemListAdapter.OnClickListener,
private val onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener
) : ListAdapter<DownloadSection, DownloadsListAdapter.SectionViewHolder>(DiffCallback) {
class SectionViewHolder(private var binding: DownloadSectionBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(
section: DownloadSection,
onClickListener: DownloadViewItemListAdapter.OnClickListener,
onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener
) {
binding.section = section
if (section.name == "Movies" || section.name == "Shows") {
binding.itemsRecyclerView.adapter =
DownloadViewItemListAdapter(onClickListener, fixedWidth = true)
(binding.itemsRecyclerView.adapter as DownloadViewItemListAdapter).submitList(section.items)
} else if (section.name == "Episodes") {
binding.itemsRecyclerView.adapter =
DownloadEpisodeListAdapter(onEpisodeClickListener)
(binding.itemsRecyclerView.adapter as DownloadEpisodeListAdapter).submitList(section.items)
}
binding.executePendingBindings()
}
}
companion object DiffCallback : DiffUtil.ItemCallback<DownloadSection>() {
override fun areItemsTheSame(oldItem: DownloadSection, newItem: DownloadSection): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: DownloadSection,
newItem: DownloadSection
): Boolean {
return oldItem == newItem
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
return SectionViewHolder(
DownloadSectionBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
val collection = getItem(position)
holder.bind(collection, onClickListener, onEpisodeClickListener)
}
}

View file

@ -0,0 +1,100 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.DownloadEpisodeListAdapter
import dev.jdtech.jellyfin.adapters.DownloadViewItemListAdapter
import dev.jdtech.jellyfin.adapters.DownloadsListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.databinding.FragmentDownloadBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.DownloadViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.*
@AndroidEntryPoint
class DownloadFragment : Fragment() {
private lateinit var binding: FragmentDownloadBinding
private val viewModel: DownloadViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentDownloadBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.downloadsRecyclerView.adapter = DownloadsListAdapter(
DownloadViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
}, DownloadEpisodeListAdapter.OnClickListener { item ->
navigateToEpisodeBottomSheetFragment(item)
})
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished ->
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE
})
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.downloadsRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.downloadsRecyclerView.visibility = View.VISIBLE
}
})
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData()
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
}
viewModel.downloadSections.observe(viewLifecycleOwner, { sections ->
if (sections.isEmpty()) {
binding.noDownloadsText.visibility = View.VISIBLE
} else {
binding.noDownloadsText.visibility = View.GONE
}
})
return binding.root
}
private fun navigateToMediaInfoFragment(item: PlayerItem) {
findNavController().navigate(
DownloadFragmentDirections.actionDownloadFragmentToMediaInfoFragment(
UUID.randomUUID(),
item.name,
item.metadata?.type ?: "Unknown",
item,
isOffline = true
)
)
}
private fun navigateToEpisodeBottomSheetFragment(episode: PlayerItem) {
findNavController().navigate(
DownloadFragmentDirections.actionDownloadFragmentToEpisodeBottomSheetFragment(
UUID.randomUUID(),
episode,
isOffline = true
)
)
}
}

View file

@ -1,5 +1,6 @@
package dev.jdtech.jellyfin.fragments
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
}

View file

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

View file

@ -0,0 +1,18 @@
package dev.jdtech.jellyfin.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.util.*
@Parcelize
data class DownloadMetadata(
val id: UUID,
val type: String?,
val seriesName: String? = null,
val name: String? = null,
val parentIndexNumber: Int? = null,
val indexNumber: Int? = null,
val playbackPosition: Long? = null,
val playedPercentage: Double? = null,
val seriesId: UUID? = null
) : Parcelable

View file

@ -0,0 +1,12 @@
package dev.jdtech.jellyfin.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.util.*
@Parcelize
data class DownloadRequestItem(
val uri: String,
val itemId: UUID,
val metadata: DownloadMetadata
) : Parcelable

View file

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

View file

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

View file

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

View file

@ -0,0 +1,247 @@
package dev.jdtech.jellyfin.utils
import android.Manifest
import android.app.DownloadManager
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.models.DownloadMetadata
import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.UserItemDataDto
import timber.log.Timber
import java.io.File
import java.util.*
fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Fragment) {
// Storage permission for downloads isn't necessary from Android 10 onwards
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
@Suppress("MagicNumber")
Timber.d("REQUESTING PERMISSION")
if (ContextCompat.checkSelfPermission(context.requireActivity(),
Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
) {
if (ActivityCompat.shouldShowRequestPermissionRationale(context.requireActivity(),
Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
ActivityCompat.requestPermissions(context.requireActivity(),
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
} else {
ActivityCompat.requestPermissions(context.requireActivity(),
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
}
}
val granted = ContextCompat.checkSelfPermission(context.requireActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
if (!granted) {
context.requireContext().toast(R.string.download_no_storage_permission)
return
}
}
val defaultStorage = getDownloadLocation(context.requireContext())
Timber.d(defaultStorage.toString())
val downloadRequest = DownloadManager.Request(uri)
.setTitle(downloadRequestItem.metadata.name)
.setDescription("Downloading")
.setDestinationUri(Uri.fromFile(File(defaultStorage, downloadRequestItem.itemId.toString())))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
if(!File(defaultStorage, downloadRequestItem.itemId.toString()).exists())
downloadFile(downloadRequest, 1, context.requireContext())
createMetadataFile(downloadRequestItem.metadata, downloadRequestItem.itemId, context.requireContext())
}
private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID, context: Context) {
val defaultStorage = getDownloadLocation(context)
val metadataFile = File(defaultStorage, "${itemId}.metadata")
metadataFile.writeText("") //This might be necessary to make sure that the metadata file is empty
if(metadata.type == "Episode") {
metadataFile.printWriter().use { out ->
out.println(metadata.id)
out.println(metadata.type.toString())
out.println(metadata.seriesName.toString())
out.println(metadata.name.toString())
out.println(metadata.parentIndexNumber.toString())
out.println(metadata.indexNumber.toString())
out.println(metadata.playbackPosition.toString())
out.println(metadata.playedPercentage.toString())
out.println(metadata.seriesId.toString())
}
} else if (metadata.type == "Movie") {
metadataFile.printWriter().use { out ->
out.println(metadata.id)
out.println(metadata.type.toString())
out.println(metadata.name.toString())
out.println(metadata.playbackPosition.toString())
out.println(metadata.playedPercentage.toString())
}
}
}
private fun downloadFile(request: DownloadManager.Request, downloadMethod: Int, context: Context) {
require(downloadMethod >= 0) { "Download method hasn't been set" }
request.apply {
setAllowedOverMetered(false)
setAllowedOverRoaming(false)
}
context.getSystemService<DownloadManager>()?.enqueue(request)
}
private fun getDownloadLocation(context: Context): File? {
return context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
}
fun loadDownloadedEpisodes(context: Context): List<PlayerItem> {
val items = mutableListOf<PlayerItem>()
val defaultStorage = getDownloadLocation(context)
defaultStorage?.walk()?.forEach {
if (it.isFile && it.extension == "") {
try{
val metadataFile = File(defaultStorage, "${it.name}.metadata").readLines()
val metadata = parseMetadataFile(metadataFile)
items.add(PlayerItem(metadata.name, UUID.fromString(it.name), "", metadata.playbackPosition!!, it.absolutePath, metadata))
} catch (e: Exception) {
it.delete()
Timber.e(e)
}
}
}
return items.toList()
}
fun deleteDownloadedEpisode(uri: String) {
try {
File(uri).delete()
File("${uri}.metadata").delete()
} catch (e: Exception) {
Timber.e(e)
}
}
fun postDownloadPlaybackProgress(uri: String, playbackPosition: Long, playedPercentage: Double) {
try {
val metadataFile = File("${uri}.metadata")
val metadataArray = metadataFile.readLines().toMutableList()
if(metadataArray[1] == "Episode"){
metadataArray[6] = playbackPosition.toString()
metadataArray[7] = playedPercentage.toString()
} else if (metadataArray[1] == "Movie") {
metadataArray[3] = playbackPosition.toString()
metadataArray[4] = playedPercentage.toString()
}
metadataFile.writeText("") //This might be necessary to make sure that the metadata file is empty
metadataFile.printWriter().use { out ->
metadataArray.forEach {
out.println(it)
}
}
} catch (e: Exception) {
Timber.e(e)
}
}
fun downloadMetadataToBaseItemDto(metadata: DownloadMetadata) : BaseItemDto {
val userData = UserItemDataDto(playbackPositionTicks = metadata.playbackPosition ?: 0,
playedPercentage = metadata.playedPercentage, isFavorite = false, playCount = 0, played = false)
return BaseItemDto(id = metadata.id,
type = metadata.type,
seriesName = metadata.seriesName,
name = metadata.name,
parentIndexNumber = metadata.parentIndexNumber,
indexNumber = metadata.indexNumber,
userData = userData,
seriesId = metadata.seriesId
)
}
fun baseItemDtoToDownloadMetadata(item: BaseItemDto) : DownloadMetadata {
return DownloadMetadata(id = item.id,
type = item.type,
seriesName = item.seriesName,
name = item.name,
parentIndexNumber = item.parentIndexNumber,
indexNumber = item.indexNumber,
playbackPosition = item.userData?.playbackPositionTicks ?: 0,
playedPercentage = item.userData?.playedPercentage,
seriesId = item.seriesId
)
}
fun parseMetadataFile(metadataFile: List<String>) : DownloadMetadata {
if (metadataFile[1] == "Episode") {
return DownloadMetadata(id = UUID.fromString(metadataFile[0]),
type = metadataFile[1],
seriesName = metadataFile[2],
name = metadataFile[3],
parentIndexNumber = metadataFile[4].toInt(),
indexNumber = metadataFile[5].toInt(),
playbackPosition = metadataFile[6].toLong(),
playedPercentage = if(metadataFile[7] == "null") {null} else {metadataFile[7].toDouble()},
seriesId = UUID.fromString(metadataFile[8])
)
} else {
return DownloadMetadata(id = UUID.fromString(metadataFile[0]),
type = metadataFile[1],
name = metadataFile[2],
playbackPosition = metadataFile[3].toLong(),
playedPercentage = if(metadataFile[4] == "null") {null} else {metadataFile[4].toDouble()},
)
}
}
suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository, context: Context) {
val items = loadDownloadedEpisodes(context)
items.forEach(){
try {
val localPlaybackProgress = it.metadata?.playbackPosition
val localPlayedPercentage = it.metadata?.playedPercentage
val item = jellyfinRepository.getItem(it.itemId)
val remotePlaybackProgress = item.userData?.playbackPositionTicks?.div(10000)
val remotePlayedPercentage = item.userData?.playedPercentage
var playbackProgress: Long = 0
var playedPercentage = 0.0
if (localPlaybackProgress != null) {
if (localPlaybackProgress > playbackProgress){
playbackProgress = localPlaybackProgress
playedPercentage = localPlayedPercentage!!
}
}
if (remotePlaybackProgress != null) {
if (remotePlaybackProgress > playbackProgress){
playbackProgress = remotePlaybackProgress
playedPercentage = remotePlayedPercentage!!
}
}
if (playbackProgress != 0.toLong()) {
postDownloadPlaybackProgress(it.mediaSourceUri, playbackProgress, playedPercentage)
jellyfinRepository.postPlaybackProgress(it.itemId, playbackProgress.times(10000), true)
Timber.d("Percentage: $playedPercentage")
}
} catch (e: Exception) {
Timber.e(e)
}
}
}

View file

@ -1,5 +1,8 @@
package dev.jdtech.jellyfin.utils
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()

View file

@ -0,0 +1,78 @@
package dev.jdtech.jellyfin.viewmodels
import android.annotation.SuppressLint
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.DownloadSection
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
import dev.jdtech.jellyfin.utils.postDownloadPlaybackProgress
import kotlinx.coroutines.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.*
import javax.inject.Inject
@HiltViewModel
class DownloadViewModel
@Inject
constructor(
private val application: Application,
) : ViewModel() {
private val _downloadSections = MutableLiveData<List<DownloadSection>>()
val downloadSections: LiveData<List<DownloadSection>> = _downloadSections
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
init {
loadData()
}
@SuppressLint("ResourceType")
fun loadData() {
_error.value = null
_finishedLoading.value = false
viewModelScope.launch {
try {
val items = loadDownloadedEpisodes(application)
if (items.isEmpty()) {
_downloadSections.value = listOf()
_finishedLoading.value = true
return@launch
}
val tempDownloadSections = mutableListOf<DownloadSection>()
withContext(Dispatchers.Default) {
DownloadSection(
UUID.randomUUID(),
"Episodes",
items.filter { it.metadata?.type == "Episode"}).let {
if (it.items.isNotEmpty()) tempDownloadSections.add(
it
)
}
DownloadSection(
UUID.randomUUID(),
"Movies",
items.filter { it.metadata?.type == "Movie" }).let {
if (it.items.isNotEmpty()) tempDownloadSections.add(
it
)
}
}
_downloadSections.value = tempDownloadSections
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
}
_finishedLoading.value = true
}
}
}

View file

@ -6,9 +6,17 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="section"
type="dev.jdtech.jellyfin.models.DownloadSection" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/section_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginBottom="12dp"
android:text="@{section.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp"
tools:text="Movies" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/items_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/home_episode_item" />
</LinearLayout>
</layout>

View file

@ -183,10 +183,31 @@
android:id="@+id/favorite_button"
android: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

View file

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

View file

@ -45,12 +45,14 @@
android:layout_height="0dp"
android: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" />

View file

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

View file

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

View file

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

View file

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