New UI state system (#71)

* Convert MediaFragment to use new UiState

* Convert PersonDetailFragment to use new UiState

* Load PersonDetail data on start

* Convert FavoriteFragment to use new UiState

* Convert SeasonFragment to use new UiState

* Convert SearchResultFragment to use new UiState

* Convert EpisodeBottomSheetFragment to use new UiState (WIP)

* Convert EpisodeBottomSheetFragment to use new UiState (Part 2)

* Convert LibraryFragment to use new UiState

* Convert DownloadFragment to use new UiState

* Convert HomeFragment to use new UiState

* Convert MediaInfoFragment to use new UiState (WIP)

* Convert MediaInfoViewModel to use new UiState (Part 2)

* Convert ServerSelectViewModel to use new UiState (Semi)

* Fix MediaInfoFragment for downloaded movies
This commit is contained in:
Jarne Demeulemeester 2021-12-19 15:35:36 +01:00 committed by GitHub
parent 00dbe8198e
commit c645ee3b81
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2396 additions and 2271 deletions

View file

@ -6,11 +6,7 @@ import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import dev.jdtech.jellyfin.adapters.CollectionListAdapter
import dev.jdtech.jellyfin.adapters.DownloadsListAdapter import dev.jdtech.jellyfin.adapters.DownloadsListAdapter
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.HomeEpisodeListAdapter
import dev.jdtech.jellyfin.adapters.HomeItem import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.adapters.PersonListAdapter import dev.jdtech.jellyfin.adapters.PersonListAdapter
@ -20,7 +16,6 @@ import dev.jdtech.jellyfin.adapters.ViewListAdapter
import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.models.DownloadSection import dev.jdtech.jellyfin.models.DownloadSection
import dev.jdtech.jellyfin.models.FavoriteSection
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
@ -32,12 +27,6 @@ fun bindServers(recyclerView: RecyclerView, data: List<Server>?) {
adapter.submitList(data) adapter.submitList(data)
} }
@BindingAdapter("views")
fun bindViews(recyclerView: RecyclerView, data: List<HomeItem>?) {
val adapter = recyclerView.adapter as ViewListAdapter
adapter.submitList(data)
}
@BindingAdapter("items") @BindingAdapter("items")
fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) { fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
val adapter = recyclerView.adapter as ViewItemListAdapter val adapter = recyclerView.adapter as ViewItemListAdapter
@ -68,12 +57,6 @@ fun bindItemBackdropById(imageView: ImageView, itemId: UUID) {
imageView.loadImage("/items/$itemId/Images/${ImageType.BACKDROP}") imageView.loadImage("/items/$itemId/Images/${ImageType.BACKDROP}")
} }
@BindingAdapter("collections")
fun bindCollections(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
val adapter = recyclerView.adapter as CollectionListAdapter
adapter.submitList(data)
}
@BindingAdapter("people") @BindingAdapter("people")
fun bindPeople(recyclerView: RecyclerView, data: List<BaseItemPerson>?) { fun bindPeople(recyclerView: RecyclerView, data: List<BaseItemPerson>?) {
val adapter = recyclerView.adapter as PersonListAdapter val adapter = recyclerView.adapter as PersonListAdapter
@ -87,12 +70,6 @@ fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) {
.posterDescription(person.name) .posterDescription(person.name)
} }
@BindingAdapter("episodes")
fun bindEpisodes(recyclerView: RecyclerView, data: List<EpisodeItem>?) {
val adapter = recyclerView.adapter as EpisodeListAdapter
adapter.submitList(data)
}
@BindingAdapter("homeEpisodes") @BindingAdapter("homeEpisodes")
fun bindHomeEpisodes(recyclerView: RecyclerView, data: List<BaseItemDto>?) { fun bindHomeEpisodes(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
val adapter = recyclerView.adapter as HomeEpisodeListAdapter val adapter = recyclerView.adapter as HomeEpisodeListAdapter
@ -136,18 +113,6 @@ fun bindSeasonPoster(imageView: ImageView, seasonId: UUID) {
imageView.loadImage("/items/${seasonId}/Images/${ImageType.PRIMARY}") imageView.loadImage("/items/${seasonId}/Images/${ImageType.PRIMARY}")
} }
@BindingAdapter("favoriteSections")
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)
}
private fun ImageView.loadImage(url: String, errorPlaceHolderId: Int? = null): View { private fun ImageView.loadImage(url: String, errorPlaceHolderId: Int? = null): View {
val api = JellyfinApi.getInstance(context.applicationContext) val api = JellyfinApi.getInstance(context.applicationContext)

View file

@ -7,6 +7,7 @@ import androidx.navigation.fragment.NavHostFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityMainTvBinding import dev.jdtech.jellyfin.databinding.ActivityMainTvBinding
import dev.jdtech.jellyfin.tv.ui.HomeFragmentDirections import dev.jdtech.jellyfin.tv.ui.HomeFragmentDirections
import dev.jdtech.jellyfin.utils.loadDownloadLocation
import dev.jdtech.jellyfin.viewmodels.MainViewModel import dev.jdtech.jellyfin.viewmodels.MainViewModel
@AndroidEntryPoint @AndroidEntryPoint
@ -24,6 +25,8 @@ internal class MainActivityTv : FragmentActivity() {
supportFragmentManager.findFragmentById(R.id.tv_nav_host) as NavHostFragment supportFragmentManager.findFragmentById(R.id.tv_nav_host) as NavHostFragment
val navController = navHostFragment.navController val navController = navHostFragment.navController
loadDownloadLocation(applicationContext)
viewModel.navigateToAddServer.observe(this, { viewModel.navigateToAddServer.observe(this, {
if (it) { if (it) {
navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment()) navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())

View file

@ -4,21 +4,23 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.DownloadEpisodeListAdapter import dev.jdtech.jellyfin.adapters.*
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.databinding.FragmentDownloadBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.DownloadViewModel import dev.jdtech.jellyfin.viewmodels.DownloadViewModel
import org.jellyfin.sdk.model.api.BaseItemDto import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.* import java.util.*
@AndroidEntryPoint @AndroidEntryPoint
@ -27,14 +29,14 @@ class DownloadFragment : Fragment() {
private lateinit var binding: FragmentDownloadBinding private lateinit var binding: FragmentDownloadBinding
private val viewModel: DownloadViewModel by viewModels() private val viewModel: DownloadViewModel by viewModels()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = FragmentDownloadBinding.inflate(inflater, container, false) binding = FragmentDownloadBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.downloadsRecyclerView.adapter = DownloadsListAdapter( binding.downloadsRecyclerView.adapter = DownloadsListAdapter(
DownloadViewItemListAdapter.OnClickListener { item -> DownloadViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item) navigateToMediaInfoFragment(item)
@ -42,40 +44,56 @@ class DownloadFragment : Fragment() {
navigateToEpisodeBottomSheetFragment(item) navigateToEpisodeBottomSheetFragment(item)
}) })
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished -> viewLifecycleOwner.lifecycleScope.launch {
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
}) viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
viewModel.error.observe(viewLifecycleOwner, { error -> when (uiState) {
if (error != null) { is DownloadViewModel.UiState.Normal -> bindUiStateNormal(uiState)
checkIfLoginRequired(error) is DownloadViewModel.UiState.Loading -> bindUiStateLoading()
binding.errorLayout.errorPanel.visibility = View.VISIBLE is DownloadViewModel.UiState.Error -> bindUiStateError(uiState)
binding.downloadsRecyclerView.visibility = View.GONE }
} else { }
binding.errorLayout.errorPanel.visibility = View.GONE
binding.downloadsRecyclerView.visibility = View.VISIBLE
} }
}) }
binding.errorLayout.errorRetryButton.setOnClickListener { binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData() viewModel.loadData()
} }
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") errorDialog.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 return binding.root
} }
private fun bindUiStateNormal(uiState: DownloadViewModel.UiState.Normal) {
uiState.apply {
binding.noDownloadsText.isVisible = downloadSections.isEmpty()
val adapter = binding.downloadsRecyclerView.adapter as DownloadsListAdapter
adapter.submitList(downloadSections)
}
binding.loadingIndicator.isVisible = false
binding.downloadsRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: DownloadViewModel.UiState.Error) {
val error = uiState.message ?: resources.getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.downloadsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToMediaInfoFragment(item: PlayerItem) { private fun navigateToMediaInfoFragment(item: PlayerItem) {
findNavController().navigate( findNavController().navigate(
DownloadFragmentDirections.actionDownloadFragmentToMediaInfoFragment( DownloadFragmentDirections.actionDownloadFragmentToMediaInfoFragment(

View file

@ -1,6 +1,5 @@
package dev.jdtech.jellyfin.fragments package dev.jdtech.jellyfin.fragments
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
@ -9,18 +8,22 @@ import android.view.ViewGroup
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.bindBaseItemImage
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.requestDownload
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.LocationType
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.*
@ -39,13 +42,10 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
): View { ): View {
binding = EpisodeBottomSheetBinding.inflate(inflater, container, false) binding = EpisodeBottomSheetBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.playButton.setOnClickListener { binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent) binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE binding.progressCircular.isVisible = true
viewModel.item.value?.let { viewModel.item?.let {
if (!args.isOffline) { if (!args.isOffline) {
playerViewModel.loadPlayerItems(it) playerViewModel.loadPlayerItems(it)
} else { } else {
@ -54,6 +54,19 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
} }
} }
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is EpisodeBottomSheetViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is EpisodeBottomSheetViewModel.UiState.Loading -> bindUiStateLoading()
is EpisodeBottomSheetViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
}
}
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) { when (playerItems) {
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
@ -61,79 +74,46 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
} }
} }
viewModel.item.observe(viewLifecycleOwner, { episode -> if(!args.isOffline) {
if (episode.userData?.playedPercentage != null) {
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
(episode.userData?.playedPercentage?.times(1.26))!!.toFloat(),
context?.resources?.displayMetrics
).toInt()
binding.progressBar.visibility = View.VISIBLE
}
binding.communityRating.visibility = when (episode.communityRating != null) {
false -> View.GONE
true -> View.VISIBLE
}
})
viewModel.played.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
binding.checkButton.setImageResource(drawable)
})
viewModel.favorite.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
binding.favoriteButton.setImageResource(drawable)
})
viewModel.downloaded.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_download_filled
false -> R.drawable.ic_download
}
binding.downloadButton.setImageResource(drawable)
})
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 val episodeId: UUID = args.episodeId
binding.checkButton.setOnClickListener { binding.checkButton.setOnClickListener {
when (viewModel.played.value) { when (viewModel.played) {
true -> viewModel.markAsUnplayed(episodeId) true -> {
false -> viewModel.markAsPlayed(episodeId) viewModel.markAsUnplayed(episodeId)
binding.checkButton.setImageResource(R.drawable.ic_check)
}
false -> {
viewModel.markAsPlayed(episodeId)
binding.checkButton.setImageResource(R.drawable.ic_check_filled)
}
} }
} }
binding.favoriteButton.setOnClickListener { binding.favoriteButton.setOnClickListener {
when (viewModel.favorite.value) { when (viewModel.favorite) {
true -> viewModel.unmarkAsFavorite(episodeId) true -> {
false -> viewModel.markAsFavorite(episodeId) viewModel.unmarkAsFavorite(episodeId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
}
false -> {
viewModel.markAsFavorite(episodeId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
}
} }
} }
binding.downloadButton.setOnClickListener { binding.downloadButton.setOnClickListener {
binding.downloadButton.isEnabled = false
viewModel.loadDownloadRequestItem(episodeId) viewModel.loadDownloadRequestItem(episodeId)
binding.downloadButton.setImageResource(android.R.color.transparent)
binding.progressDownload.isVisible = true
} }
binding.deleteButton.visibility = View.GONE binding.deleteButton.isVisible = false
viewModel.loadEpisode(episodeId) viewModel.loadEpisode(episodeId)
}else { } else {
val playerItem = args.playerItem!! val playerItem = args.playerItem!!
viewModel.loadEpisode(playerItem) viewModel.loadEpisode(playerItem)
@ -143,14 +123,67 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
findNavController().navigate(R.id.downloadFragment) findNavController().navigate(R.id.downloadFragment)
} }
binding.checkButton.visibility = View.GONE binding.checkButton.isVisible = false
binding.favoriteButton.visibility = View.GONE binding.favoriteButton.isVisible = false
binding.downloadButton.visibility = View.GONE binding.downloadButtonWrapper.isVisible = false
} }
return binding.root return binding.root
} }
private fun bindUiStateNormal(uiState: EpisodeBottomSheetViewModel.UiState.Normal) {
uiState.apply {
if (episode.userData?.playedPercentage != null) {
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
(episode.userData?.playedPercentage?.times(1.26))!!.toFloat(),
context?.resources?.displayMetrics
).toInt()
binding.progressBar.isVisible = true
}
// Check icon
val checkDrawable = when (played) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
binding.checkButton.setImageResource(checkDrawable)
// Favorite icon
val favoriteDrawable = when (favorite) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
binding.favoriteButton.setImageResource(favoriteDrawable)
// Download icon
val downloadDrawable = when (downloaded) {
true -> R.drawable.ic_download_filled
false -> R.drawable.ic_download
}
binding.downloadButton.setImageResource(downloadDrawable)
binding.episodeName.text = String.format(getString(R.string.episode_name_extended), episode.parentIndexNumber, episode.indexNumber, episode.name)
binding.overview.text = episode.overview
binding.year.text = dateString
binding.playtime.text = runTime
binding.communityRating.isVisible = episode.communityRating != null
binding.communityRating.text = episode.communityRating.toString()
binding.missingIcon.isVisible = episode.locationType == LocationType.VIRTUAL
bindBaseItemImage(binding.episodeImage, episode)
}
binding.loadingIndicator.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
}
private fun bindUiStateError(uiState: EpisodeBottomSheetViewModel.UiState.Error) {
binding.loadingIndicator.isVisible = false
binding.overview.text = uiState.message
}
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
navigateToPlayerActivity(items.items.toTypedArray()) navigateToPlayerActivity(items.items.toTypedArray())
binding.playButton.setImageDrawable( binding.playButton.setImageDrawable(

View file

@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
@ -16,7 +20,9 @@ import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class FavoriteFragment : Fragment() { class FavoriteFragment : Fragment() {
@ -24,14 +30,14 @@ class FavoriteFragment : Fragment() {
private lateinit var binding: FragmentFavoriteBinding private lateinit var binding: FragmentFavoriteBinding
private val viewModel: FavoriteViewModel by viewModels() private val viewModel: FavoriteViewModel by viewModels()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = FragmentFavoriteBinding.inflate(inflater, container, false) binding = FragmentFavoriteBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.favoritesRecyclerView.adapter = FavoritesListAdapter( binding.favoritesRecyclerView.adapter = FavoritesListAdapter(
ViewItemListAdapter.OnClickListener { item -> ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item) navigateToMediaInfoFragment(item)
@ -39,40 +45,56 @@ class FavoriteFragment : Fragment() {
navigateToEpisodeBottomSheetFragment(item) navigateToEpisodeBottomSheetFragment(item)
}) })
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished -> viewLifecycleOwner.lifecycleScope.launch {
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
}) viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
viewModel.error.observe(viewLifecycleOwner, { error -> when (uiState) {
if (error != null) { is FavoriteViewModel.UiState.Normal -> bindUiStateNormal(uiState)
checkIfLoginRequired(error) is FavoriteViewModel.UiState.Loading -> bindUiStateLoading()
binding.errorLayout.errorPanel.visibility = View.VISIBLE is FavoriteViewModel.UiState.Error -> bindUiStateError(uiState)
binding.favoritesRecyclerView.visibility = View.GONE }
} else { }
binding.errorLayout.errorPanel.visibility = View.GONE
binding.favoritesRecyclerView.visibility = View.VISIBLE
} }
}) }
binding.errorLayout.errorRetryButton.setOnClickListener { binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData() viewModel.loadData()
} }
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") errorDialog.show(parentFragmentManager, "errordialog")
} }
viewModel.favoriteSections.observe(viewLifecycleOwner, { sections ->
if (sections.isEmpty()) {
binding.noFavoritesText.visibility = View.VISIBLE
} else {
binding.noFavoritesText.visibility = View.GONE
}
})
return binding.root return binding.root
} }
private fun bindUiStateNormal(uiState: FavoriteViewModel.UiState.Normal) {
uiState.apply {
binding.noFavoritesText.isVisible = favoriteSections.isEmpty()
val adapter = binding.favoritesRecyclerView.adapter as FavoritesListAdapter
adapter.submitList(favoriteSections)
}
binding.loadingIndicator.isVisible = false
binding.favoritesRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: FavoriteViewModel.UiState.Error) {
val error = uiState.message ?: resources.getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.favoritesRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) { private fun navigateToMediaInfoFragment(item: BaseItemDto) {
findNavController().navigate( findNavController().navigate(
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment( FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(

View file

@ -12,7 +12,9 @@ import android.widget.Toast.LENGTH_LONG
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
@ -28,9 +30,9 @@ import dev.jdtech.jellyfin.models.ContentType.TVSHOW
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.utils.contentType import dev.jdtech.jellyfin.utils.contentType
import dev.jdtech.jellyfin.viewmodels.HomeViewModel import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import dev.jdtech.jellyfin.viewmodels.HomeViewModel.Loading import kotlinx.coroutines.launch
import dev.jdtech.jellyfin.viewmodels.HomeViewModel.LoadingError
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
@ -38,6 +40,8 @@ class HomeFragment : Fragment() {
private lateinit var binding: FragmentHomeBinding private lateinit var binding: FragmentHomeBinding
private val viewModel: HomeViewModel by viewModels() private val viewModel: HomeViewModel by viewModels()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -66,9 +70,6 @@ class HomeFragment : Fragment() {
): View { ): View {
binding = FragmentHomeBinding.inflate(inflater, container, false) binding = FragmentHomeBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
setupView() setupView()
bindState() bindState()
@ -84,6 +85,7 @@ class HomeFragment : Fragment() {
private fun setupView() { private fun setupView() {
binding.refreshLayout.setOnRefreshListener { binding.refreshLayout.setOnRefreshListener {
viewModel.refreshData() viewModel.refreshData()
// binding.refreshLayout.isRefreshing = false
} }
binding.viewsRecyclerView.adapter = ViewListAdapter( binding.viewsRecyclerView.adapter = ViewListAdapter(
@ -99,50 +101,56 @@ class HomeFragment : Fragment() {
.show() .show()
} }
}) })
}
private fun bindState() {
viewModel.onStateUpdate(lifecycleScope) { state ->
when (state) {
is Loading -> bindLoading(state)
is LoadingError -> bindError(state)
}
}
}
private fun bindError(state: LoadingError) {
checkIfLoginRequired(state.message)
binding.errorLayout.errorPanel.isVisible = true
binding.viewsRecyclerView.isVisible = false
binding.loadingIndicator.isVisible = false
binding.refreshLayout.isRefreshing = false
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(state.message).show(
parentFragmentManager,
"errordialog"
)
}
binding.errorLayout.errorRetryButton.setOnClickListener { binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.refreshData() viewModel.refreshData()
} }
binding.errorLayout.errorDetailsButton.setOnClickListener {
errorDialog.show(parentFragmentManager, "errordialog")
}
} }
private fun bindLoading(state: Loading) { private fun bindState() {
binding.errorLayout.errorPanel.isVisible = false viewLifecycleOwner.lifecycleScope.launch {
binding.viewsRecyclerView.isVisible = true viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
binding.loadingIndicator.visibility = when { Timber.d("$uiState")
state.inProgress && binding.refreshLayout.isRefreshing -> View.GONE when (uiState) {
state.inProgress -> View.VISIBLE is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState)
else -> { is HomeViewModel.UiState.Loading -> bindUiStateLoading()
binding.refreshLayout.isRefreshing = false is HomeViewModel.UiState.Error -> bindUiStateError(uiState)
View.GONE }
}
} }
} }
} }
private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) {
uiState.apply {
val adapter = binding.viewsRecyclerView.adapter as ViewListAdapter
adapter.submitList(uiState.homeItems)
}
binding.loadingIndicator.isVisible = false
binding.refreshLayout.isRefreshing = false
binding.viewsRecyclerView.isVisible = true
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: HomeViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.refreshLayout.isRefreshing = false
binding.viewsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToLibraryFragment(view: dev.jdtech.jellyfin.models.View) { private fun navigateToLibraryFragment(view: dev.jdtech.jellyfin.models.View) {
findNavController().navigate( findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToLibraryFragment( HomeFragmentDirections.actionNavigationHomeToLibraryFragment(

View file

@ -3,8 +3,12 @@ package dev.jdtech.jellyfin.fragments
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -16,6 +20,7 @@ import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.SortDialogFragment import dev.jdtech.jellyfin.dialogs.SortDialogFragment
import dev.jdtech.jellyfin.utils.SortBy import dev.jdtech.jellyfin.utils.SortBy
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import java.lang.IllegalArgumentException import java.lang.IllegalArgumentException
@ -26,9 +31,10 @@ class LibraryFragment : Fragment() {
private lateinit var binding: FragmentLibraryBinding private lateinit var binding: FragmentLibraryBinding
private val viewModel: LibraryViewModel by viewModels() private val viewModel: LibraryViewModel by viewModels()
private val args: LibraryFragmentArgs by navArgs() private val args: LibraryFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
@Inject @Inject
lateinit var sp: SharedPreferences lateinit var sp: SharedPreferences
@ -67,56 +73,71 @@ class LibraryFragment : Fragment() {
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = FragmentLibraryBinding.inflate(inflater, container, false) binding = FragmentLibraryBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.itemsRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.itemsRecyclerView.visibility = View.VISIBLE
}
})
binding.errorLayout.errorRetryButton.setOnClickListener { binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadItems(args.libraryId, args.libraryType) viewModel.loadItems(args.libraryId, args.libraryType)
} }
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show( errorDialog.show(
parentFragmentManager, parentFragmentManager,
"errordialog" "errordialog"
) )
} }
viewModel.finishedLoading.observe(viewLifecycleOwner, {
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
})
binding.itemsRecyclerView.adapter = binding.itemsRecyclerView.adapter =
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { item -> ViewItemListAdapter(ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item) navigateToMediaInfoFragment(item)
}) })
// Sorting options viewLifecycleOwner.lifecycleScope.launch {
val sortBy = SortBy.fromString(sp.getString("sortBy", SortBy.defaultValue.name)!!) viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
val sortOrder = try { viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
SortOrder.valueOf(sp.getString("sortOrder", SortOrder.ASCENDING.name)!!) when (uiState) {
} catch (e: IllegalArgumentException) { is LibraryViewModel.UiState.Normal -> bindUiStateNormal(uiState)
SortOrder.ASCENDING is LibraryViewModel.UiState.Loading -> bindUiStateLoading()
} is LibraryViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
viewModel.loadItems(args.libraryId, args.libraryType, sortBy = sortBy, sortOrder = sortOrder) // Sorting options
val sortBy = SortBy.fromString(sp.getString("sortBy", SortBy.defaultValue.name)!!)
val sortOrder = try {
SortOrder.valueOf(sp.getString("sortOrder", SortOrder.ASCENDING.name)!!)
} catch (e: IllegalArgumentException) {
SortOrder.ASCENDING
}
viewModel.loadItems(args.libraryId, args.libraryType, sortBy = sortBy, sortOrder = sortOrder)
}
}
}
private fun bindUiStateNormal(uiState: LibraryViewModel.UiState.Normal) {
val adapter = binding.itemsRecyclerView.adapter as ViewItemListAdapter
adapter.submitList(uiState.items)
binding.loadingIndicator.isVisible = false
binding.itemsRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: LibraryViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.itemsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
} }
private fun navigateToMediaInfoFragment(item: BaseItemDto) { private fun navigateToMediaInfoFragment(item: BaseItemDto) {

View file

@ -3,8 +3,12 @@ package dev.jdtech.jellyfin.fragments
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
@ -13,7 +17,9 @@ import dev.jdtech.jellyfin.databinding.FragmentMediaBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.MediaViewModel import dev.jdtech.jellyfin.viewmodels.MediaViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class MediaFragment : Fragment() { class MediaFragment : Fragment() {
@ -23,6 +29,8 @@ class MediaFragment : Fragment() {
private var originalSoftInputMode: Int? = null private var originalSoftInputMode: Int? = null
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -56,37 +64,30 @@ class MediaFragment : Fragment() {
): View { ): View {
binding = FragmentMediaBinding.inflate(inflater, container, false) binding = FragmentMediaBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.viewsRecyclerView.adapter = binding.viewsRecyclerView.adapter =
CollectionListAdapter(CollectionListAdapter.OnClickListener { library -> CollectionListAdapter(CollectionListAdapter.OnClickListener { library ->
navigateToLibraryFragment(library) navigateToLibraryFragment(library)
}) })
viewModel.finishedLoading.observe(viewLifecycleOwner, { viewLifecycleOwner.lifecycleScope.launch {
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
}) viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
viewModel.error.observe(viewLifecycleOwner, { error -> when (uiState) {
if (error != null) { is MediaViewModel.UiState.Normal -> bindUiStateNormal(uiState)
checkIfLoginRequired(error) is MediaViewModel.UiState.Loading -> bindUiStateLoading()
binding.errorLayout.errorPanel.visibility = View.VISIBLE is MediaViewModel.UiState.Error -> bindUiStateError(uiState)
binding.viewsRecyclerView.visibility = View.GONE }
} else { }
binding.errorLayout.errorPanel.visibility = View.GONE
binding.viewsRecyclerView.visibility = View.VISIBLE
} }
}) }
binding.errorLayout.errorRetryButton.setOnClickListener { binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData() viewModel.loadData()
} }
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show( errorDialog.show(parentFragmentManager, "errordialog")
parentFragmentManager,
"errordialog"
)
} }
return binding.root return binding.root
@ -105,6 +106,29 @@ class MediaFragment : Fragment() {
originalSoftInputMode?.let { activity?.window?.setSoftInputMode(it) } originalSoftInputMode?.let { activity?.window?.setSoftInputMode(it) }
} }
private fun bindUiStateNormal(uiState: MediaViewModel.UiState.Normal) {
binding.loadingIndicator.isVisible = false
binding.viewsRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
val adapter = binding.viewsRecyclerView.adapter as CollectionListAdapter
adapter.submitList(uiState.collections)
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: MediaViewModel.UiState.Error) {
val error = uiState.message ?: resources.getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.viewsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToLibraryFragment(library: BaseItemDto) { private fun navigateToLibraryFragment(library: BaseItemDto) {
findNavController().navigate( findNavController().navigate(
MediaFragmentDirections.actionNavigationMediaToLibraryFragment( MediaFragmentDirections.actionNavigationMediaToLibraryFragment(

View file

@ -8,23 +8,28 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.PersonListAdapter import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.bindBaseItemImage
import dev.jdtech.jellyfin.bindItemBackdropImage
import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.utils.requestDownload
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber import timber.log.Timber
@ -36,35 +41,39 @@ class MediaInfoFragment : Fragment() {
private lateinit var binding: FragmentMediaInfoBinding private lateinit var binding: FragmentMediaInfoBinding
private val viewModel: MediaInfoViewModel by viewModels() private val viewModel: MediaInfoViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels() private val playerViewModel: PlayerViewModel by viewModels()
private val args: MediaInfoFragmentArgs by navArgs() private val args: MediaInfoFragmentArgs by navArgs()
lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = FragmentMediaInfoBinding.inflate(inflater, container, false) binding = FragmentMediaInfoBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.error.observe(viewLifecycleOwner, { error -> viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
if (error != null) { Timber.d("$uiState")
checkIfLoginRequired(error) when (uiState) {
binding.errorLayout.errorPanel.visibility = View.VISIBLE is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState)
binding.mediaInfoScrollview.visibility = View.GONE is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading()
} else { is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState)
binding.errorLayout.errorPanel.visibility = View.GONE }
binding.mediaInfoScrollview.visibility = View.VISIBLE }
if (!args.isOffline) {
viewModel.loadData(args.itemId, args.itemType)
} else {
viewModel.loadData(args.playerItem!!)
}
} }
}) }
if(args.itemType != "Movie") { if(args.itemType != "Movie") {
binding.downloadButton.visibility = View.GONE binding.downloadButton.visibility = View.GONE
@ -74,36 +83,6 @@ class MediaInfoFragment : Fragment() {
viewModel.loadData(args.itemId, args.itemType) viewModel.loadData(args.itemId, args.itemType)
} }
viewModel.downloadMedia.observe(viewLifecycleOwner, {
if (it) {
requestDownload(Uri.parse(viewModel.downloadRequestItem.uri), viewModel.downloadRequestItem, this)
viewModel.doneDownloadMedia()
}
})
viewModel.item.observe(viewLifecycleOwner, { item ->
if (item.originalTitle != item.name) {
binding.originalTitle.visibility = View.VISIBLE
} else {
binding.originalTitle.visibility = View.GONE
}
if (item.remoteTrailers.isNullOrEmpty()) {
binding.trailerButton.visibility = View.GONE
}
binding.communityRating.visibility = when (item.communityRating != null) {
true -> View.VISIBLE
false -> View.GONE
}
Timber.d(item.seasonId.toString())
})
viewModel.actors.observe(viewLifecycleOwner, { actors ->
when (actors.isNullOrEmpty()) {
false -> binding.actors.visibility = View.VISIBLE
true -> binding.actors.visibility = View.GONE
}
})
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) { when (playerItems) {
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
@ -111,44 +90,17 @@ class MediaInfoFragment : Fragment() {
} }
} }
viewModel.played.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
binding.checkButton.setImageResource(drawable)
})
viewModel.favorite.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
binding.favoriteButton.setImageResource(drawable)
})
viewModel.downloaded.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_download_filled
false -> R.drawable.ic_download
}
binding.downloadButton.setImageResource(drawable)
})
binding.trailerButton.setOnClickListener { binding.trailerButton.setOnClickListener {
if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
val intent = Intent( val intent = Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW,
Uri.parse(viewModel.item.value?.remoteTrailers?.get(0)?.url) Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url)
) )
startActivity(intent) startActivity(intent)
} }
binding.nextUp.setOnClickListener { binding.nextUp.setOnClickListener {
navigateToEpisodeBottomSheetFragment(viewModel.nextUp.value!!) navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!)
} }
binding.seasonsRecyclerView.adapter = binding.seasonsRecyclerView.adapter =
@ -166,9 +118,8 @@ class MediaInfoFragment : Fragment() {
binding.playButton.setOnClickListener { binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent) binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE binding.progressCircular.isVisible = true
viewModel.item?.let { item ->
viewModel.item.value?.let { item ->
if (!args.isOffline) { if (!args.isOffline) {
playerViewModel.loadPlayerItems(item) { playerViewModel.loadPlayerItems(item) {
VideoVersionDialogFragment(item, playerViewModel).show( VideoVersionDialogFragment(item, playerViewModel).show(
@ -188,16 +139,28 @@ class MediaInfoFragment : Fragment() {
} }
binding.checkButton.setOnClickListener { binding.checkButton.setOnClickListener {
when (viewModel.played.value) { when (viewModel.played) {
true -> viewModel.markAsUnplayed(args.itemId) true -> {
false -> viewModel.markAsPlayed(args.itemId) viewModel.markAsUnplayed(args.itemId)
binding.checkButton.setImageResource(R.drawable.ic_check)
}
false -> {
viewModel.markAsPlayed(args.itemId)
binding.checkButton.setImageResource(R.drawable.ic_check_filled)
}
} }
} }
binding.favoriteButton.setOnClickListener { binding.favoriteButton.setOnClickListener {
when (viewModel.favorite.value) { when (viewModel.favorite) {
true -> viewModel.unmarkAsFavorite(args.itemId) true -> {
false -> viewModel.markAsFavorite(args.itemId) viewModel.unmarkAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
}
false -> {
viewModel.markAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
}
} }
} }
@ -205,23 +168,90 @@ class MediaInfoFragment : Fragment() {
viewModel.loadDownloadRequestItem(args.itemId) viewModel.loadDownloadRequestItem(args.itemId)
} }
binding.deleteButton.visibility = View.GONE binding.deleteButton.isVisible = false
viewModel.loadData(args.itemId, args.itemType)
} else { } else {
binding.favoriteButton.visibility = View.GONE binding.favoriteButton.isVisible = false
binding.checkButton.visibility = View.GONE binding.checkButton.isVisible = false
binding.downloadButton.visibility = View.GONE binding.downloadButton.isVisible = false
binding.deleteButton.setOnClickListener { binding.deleteButton.setOnClickListener {
viewModel.deleteItem() viewModel.deleteItem()
findNavController().navigate(R.id.downloadFragment) findNavController().navigate(R.id.downloadFragment)
} }
viewModel.loadData(args.playerItem!!)
} }
} }
private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) {
uiState.apply {
binding.originalTitle.isVisible = item.originalTitle != item.name
if (item.remoteTrailers.isNullOrEmpty()) {
binding.trailerButton.isVisible = false
}
binding.communityRating.isVisible = item.communityRating != null
binding.actors.isVisible = actors.isNotEmpty()
// Check icon
val checkDrawable = when (played) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
binding.checkButton.setImageResource(checkDrawable)
// Favorite icon
val favoriteDrawable = when (favorite) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
binding.favoriteButton.setImageResource(favoriteDrawable)
// Download icon
val downloadDrawable = when (downloaded) {
true -> R.drawable.ic_download_filled
false -> R.drawable.ic_download
}
binding.downloadButton.setImageResource(downloadDrawable)
binding.name.text = item.name
binding.originalTitle.text = item.originalTitle
if (dateString.isEmpty()) {
binding.year.isVisible = false
} else {
binding.year.text = dateString
}
if (runTime.isEmpty()) {
binding.playtime.isVisible = false
} else {
binding.playtime.text = runTime
}
binding.officialRating.text = item.officialRating
binding.communityRating.text = item.communityRating.toString()
binding.genresLayout.isVisible = item.genres?.isNotEmpty() ?: false
binding.genres.text = genresString
binding.directorLayout.isVisible = director != null
binding.director.text = director?.name
binding.writersLayout.isVisible = writers.isNotEmpty()
binding.writers.text = writersString
binding.description.text = item.overview
binding.nextUpLayout.isVisible = nextUp != null
binding.nextUpName.text = String.format(getString(R.string.episode_name_extended), nextUp?.parentIndexNumber, nextUp?.indexNumber, nextUp?.name)
binding.seasonsLayout.isVisible = seasons.isNotEmpty()
val seasonsAdapter = binding.seasonsRecyclerView.adapter as ViewItemListAdapter
seasonsAdapter.submitList(seasons)
val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter
actorsAdapter.submitList(actors)
bindItemBackdropImage(binding.itemBanner, item)
bindBaseItemImage(binding.nextUpImage, nextUp)
}
}
private fun bindUiStateLoading() {}
private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
binding.mediaInfoScrollview.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
navigateToPlayerActivity(items.items.toTypedArray()) navigateToPlayerActivity(items.items.toTypedArray())
binding.playButton.setImageDrawable( binding.playButton.setImageDrawable(

View file

@ -9,6 +9,9 @@ import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -19,7 +22,9 @@ import dev.jdtech.jellyfin.databinding.FragmentPersonDetailBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel import dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
internal class PersonDetailFragment : Fragment() { internal class PersonDetailFragment : Fragment() {
@ -29,15 +34,14 @@ internal class PersonDetailFragment : Fragment() {
private val args: PersonDetailFragmentArgs by navArgs() private val args: PersonDetailFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = FragmentPersonDetailBinding.inflate(inflater, container, false) binding = FragmentPersonDetailBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
return binding.root return binding.root
} }
@ -47,42 +51,65 @@ internal class PersonDetailFragment : Fragment() {
binding.moviesList.adapter = adapter() binding.moviesList.adapter = adapter()
binding.showList.adapter = adapter() binding.showList.adapter = adapter()
viewModel.data.observe(viewLifecycleOwner) { data -> viewLifecycleOwner.lifecycleScope.launch {
binding.name.text = data.name viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
binding.overview.text = data.overview viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
setupOverviewExpansion() when (uiState) {
is PersonDetailViewModel.UiState.Normal -> bindUiStateNormal(uiState)
bindItemImage(binding.personImage, data.dto) is PersonDetailViewModel.UiState.Loading -> bindUiStateLoading()
} is PersonDetailViewModel.UiState.Error -> bindUiStateError(uiState)
}
viewModel.finishedLoading.observe(viewLifecycleOwner, { }
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE viewModel.loadData(args.personId)
})
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.fragmentContent.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.fragmentContent.visibility = View.VISIBLE
} }
}) }
binding.errorLayout.errorRetryButton.setOnClickListener { binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.personId) viewModel.loadData(args.personId)
} }
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show( errorDialog.show(parentFragmentManager, "errordialog")
parentFragmentManager, }
"errordialog" }
)
private fun bindUiStateNormal(uiState: PersonDetailViewModel.UiState.Normal) {
uiState.apply {
binding.name.text = data.name
binding.overview.text = data.overview
setupOverviewExpansion()
bindItemImage(binding.personImage, data.dto)
if (starredIn.movies.isNotEmpty()) {
binding.movieLabel.isVisible = true
val moviesAdapter = binding.moviesList.adapter as ViewItemListAdapter
moviesAdapter.submitList(starredIn.movies)
}
if (starredIn.shows.isNotEmpty()) {
binding.showLabel.isVisible = true
val showsAdapter = binding.showList.adapter as ViewItemListAdapter
showsAdapter.submitList(starredIn.shows)
}
} }
viewModel.loadData(args.personId) binding.loadingIndicator.isVisible = false
binding.fragmentContent.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: PersonDetailViewModel.UiState.Error) {
val error = uiState.message ?: resources.getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.fragmentContent.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
} }
private fun adapter() = ViewItemListAdapter( private fun adapter() = ViewItemListAdapter(
@ -103,7 +130,6 @@ internal class PersonDetailFragment : Fragment() {
binding.overviewGradient.isVisible = false binding.overviewGradient.isVisible = false
} }
} }
} }
} }

View file

@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -17,24 +21,25 @@ import dev.jdtech.jellyfin.databinding.FragmentSearchResultBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class SearchResultFragment : Fragment() { class SearchResultFragment : Fragment() {
private lateinit var binding: FragmentSearchResultBinding private lateinit var binding: FragmentSearchResultBinding
private val viewModel: SearchResultViewModel by viewModels() private val viewModel: SearchResultViewModel by viewModels()
private val args: SearchResultFragmentArgs by navArgs() private val args: SearchResultFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = FragmentSearchResultBinding.inflate(inflater, container, false) binding = FragmentSearchResultBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.searchResultsRecyclerView.adapter = FavoritesListAdapter( binding.searchResultsRecyclerView.adapter = FavoritesListAdapter(
ViewItemListAdapter.OnClickListener { item -> ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item) navigateToMediaInfoFragment(item)
@ -42,42 +47,58 @@ class SearchResultFragment : Fragment() {
navigateToEpisodeBottomSheetFragment(item) navigateToEpisodeBottomSheetFragment(item)
}) })
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished -> viewLifecycleOwner.lifecycleScope.launch {
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
}) viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
viewModel.error.observe(viewLifecycleOwner, { error -> when (uiState) {
if (error != null) { is SearchResultViewModel.UiState.Normal -> bindUiStateNormal(uiState)
checkIfLoginRequired(error) is SearchResultViewModel.UiState.Loading -> bindUiStateLoading()
binding.errorLayout.errorPanel.visibility = View.VISIBLE is SearchResultViewModel.UiState.Error -> bindUiStateError(uiState)
binding.searchResultsRecyclerView.visibility = View.GONE }
} else { }
binding.errorLayout.errorPanel.visibility = View.GONE viewModel.loadData(args.query)
binding.searchResultsRecyclerView.visibility = View.VISIBLE
} }
}) }
binding.errorLayout.errorRetryButton.setOnClickListener { binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.query) viewModel.loadData(args.query)
} }
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") errorDialog.show(parentFragmentManager, "errordialog")
} }
viewModel.sections.observe(viewLifecycleOwner, { sections ->
if (sections.isEmpty()) {
binding.noSearchResultsText.visibility = View.VISIBLE
} else {
binding.noSearchResultsText.visibility = View.GONE
}
})
viewModel.loadData(args.query)
return binding.root return binding.root
} }
private fun bindUiStateNormal(uiState: SearchResultViewModel.UiState.Normal) {
uiState.apply {
binding.noSearchResultsText.isVisible = sections.isEmpty()
val adapter = binding.searchResultsRecyclerView.adapter as FavoritesListAdapter
adapter.submitList(uiState.sections)
}
binding.loadingIndicator.isVisible = false
binding.searchResultsRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: SearchResultViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.searchResultsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) { private fun navigateToMediaInfoFragment(item: BaseItemDto) {
findNavController().navigate( findNavController().navigate(
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment( FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(

View file

@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -15,58 +19,81 @@ import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class SeasonFragment : Fragment() { class SeasonFragment : Fragment() {
private lateinit var binding: FragmentSeasonBinding private lateinit var binding: FragmentSeasonBinding
private val viewModel: SeasonViewModel by viewModels() private val viewModel: SeasonViewModel by viewModels()
private val args: SeasonFragmentArgs by navArgs() private val args: SeasonFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = FragmentSeasonBinding.inflate(inflater, container, false) binding = FragmentSeasonBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
viewModel.error.observe(viewLifecycleOwner, { error -> viewLifecycleOwner.lifecycleScope.launch {
if (error != null) { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
checkIfLoginRequired(error) viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
binding.errorLayout.errorPanel.visibility = View.VISIBLE Timber.d("$uiState")
binding.episodesRecyclerView.visibility = View.GONE when (uiState) {
} else { is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState)
binding.errorLayout.errorPanel.visibility = View.GONE is SeasonViewModel.UiState.Loading -> bindUiStateLoading()
binding.episodesRecyclerView.visibility = View.VISIBLE is SeasonViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
viewModel.loadEpisodes(args.seriesId, args.seasonId)
} }
}) }
binding.errorLayout.errorRetryButton.setOnClickListener { binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadEpisodes(args.seriesId, args.seasonId) viewModel.loadEpisodes(args.seriesId, args.seasonId)
} }
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") errorDialog.show(parentFragmentManager, "errordialog")
} }
viewModel.finishedLoading.observe(viewLifecycleOwner, {
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
})
binding.episodesRecyclerView.adapter = binding.episodesRecyclerView.adapter =
EpisodeListAdapter(EpisodeListAdapter.OnClickListener { episode -> EpisodeListAdapter(EpisodeListAdapter.OnClickListener { episode ->
navigateToEpisodeBottomSheetFragment(episode) navigateToEpisodeBottomSheetFragment(episode)
}, args.seriesId, args.seriesName, args.seasonId, args.seasonName) }, args.seriesId, args.seriesName, args.seasonId, args.seasonName)
viewModel.loadEpisodes(args.seriesId, args.seasonId) }
private fun bindUiStateNormal(uiState: SeasonViewModel.UiState.Normal) {
uiState.apply {
val adapter = binding.episodesRecyclerView.adapter as EpisodeListAdapter
adapter.submitList(uiState.episodes)
}
binding.loadingIndicator.isVisible = false
binding.episodesRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: SeasonViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.episodesRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
} }
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) { private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {

View file

@ -6,12 +6,16 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.FragmentServerSelectBinding import dev.jdtech.jellyfin.databinding.FragmentServerSelectBinding
import dev.jdtech.jellyfin.dialogs.DeleteServerDialogFragment import dev.jdtech.jellyfin.dialogs.DeleteServerDialogFragment
import dev.jdtech.jellyfin.adapters.ServerGridAdapter import dev.jdtech.jellyfin.adapters.ServerGridAdapter
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
import kotlinx.coroutines.launch
@AndroidEntryPoint @AndroidEntryPoint
class ServerSelectFragment : Fragment() { class ServerSelectFragment : Fragment() {
@ -44,11 +48,15 @@ class ServerSelectFragment : Fragment() {
navigateToAddServerFragment() navigateToAddServerFragment()
} }
viewModel.navigateToMain.observe(viewLifecycleOwner, { viewLifecycleOwner.lifecycleScope.launch {
if (it) { repeatOnLifecycle(Lifecycle.State.STARTED) {
navigateToMainActivity() viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
if (it) {
navigateToMainActivity()
}
}
} }
}) }
return binding.root return binding.root
} }
@ -61,6 +69,5 @@ class ServerSelectFragment : Fragment() {
private fun navigateToMainActivity() { private fun navigateToMainActivity() {
findNavController().navigate(ServerSelectFragmentDirections.actionServerSelectFragmentToHomeFragment()) findNavController().navigate(ServerSelectFragmentDirections.actionServerSelectFragmentToHomeFragment())
viewModel.doneNavigatingToMain()
} }
} }

View file

@ -5,15 +5,17 @@ import dev.jdtech.jellyfin.models.CollectionType.HomeVideos
import dev.jdtech.jellyfin.models.CollectionType.LiveTv import dev.jdtech.jellyfin.models.CollectionType.LiveTv
import dev.jdtech.jellyfin.models.CollectionType.Music import dev.jdtech.jellyfin.models.CollectionType.Music
import dev.jdtech.jellyfin.models.CollectionType.Playlists import dev.jdtech.jellyfin.models.CollectionType.Playlists
import dev.jdtech.jellyfin.models.CollectionType.BoxSets
enum class CollectionType (val type: String) { enum class CollectionType (val type: String) {
HomeVideos("homevideos"), HomeVideos("homevideos"),
Music("music"), Music("music"),
Playlists("playlists"), Playlists("playlists"),
Books("books"), Books("books"),
LiveTv("livetv") LiveTv("livetv"),
BoxSets("boxsets")
} }
fun unsupportedCollections() = listOf( fun unsupportedCollections() = listOf(
HomeVideos, Music, Playlists, Books, LiveTv HomeVideos, Music, Playlists, Books, LiveTv, BoxSets
) )

View file

@ -11,12 +11,17 @@ import androidx.leanback.widget.ArrayObjectAdapter
import androidx.leanback.widget.HeaderItem import androidx.leanback.widget.HeaderItem
import androidx.leanback.widget.ListRow import androidx.leanback.widget.ListRow
import androidx.leanback.widget.ListRowPresenter import androidx.leanback.widget.ListRowPresenter
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.HomeItem import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.viewmodels.HomeViewModel import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
internal class HomeFragment : BrowseSupportFragment() { internal class HomeFragment : BrowseSupportFragment() {
@ -48,7 +53,22 @@ internal class HomeFragment : BrowseSupportFragment() {
setOnClickListener { navigateToSettingsFragment() } setOnClickListener { navigateToSettingsFragment() }
} }
viewModel.views().observe(viewLifecycleOwner) { homeItems -> viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is HomeViewModel.UiState.Loading -> Unit
is HomeViewModel.UiState.Error -> Unit
}
}
}
}
}
private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) {
uiState.apply {
rowsAdapter.clear() rowsAdapter.clear()
homeItems.map { section -> rowsAdapter.add(section.toListRow()) } homeItems.map { section -> rowsAdapter.add(section.toListRow()) }
} }

View file

@ -11,22 +11,24 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.PersonListAdapter import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.bindBaseItemImage
import dev.jdtech.jellyfin.databinding.MediaDetailFragmentBinding import dev.jdtech.jellyfin.databinding.MediaDetailFragmentBinding
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.tv.ui.MediaDetailViewModel.State.Movie
import dev.jdtech.jellyfin.tv.ui.MediaDetailViewModel.State.TvShow
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItemError import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItemError
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItems import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItems
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
@ -35,7 +37,6 @@ internal class MediaDetailFragment : Fragment() {
private lateinit var binding: MediaDetailFragmentBinding private lateinit var binding: MediaDetailFragmentBinding
private val viewModel: MediaInfoViewModel by viewModels() private val viewModel: MediaInfoViewModel by viewModels()
private val detailViewModel: MediaDetailViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels() private val playerViewModel: PlayerViewModel by viewModels()
private val args: MediaDetailFragmentArgs by navArgs() private val args: MediaDetailFragmentArgs by navArgs()
@ -52,28 +53,29 @@ internal class MediaDetailFragment : Fragment() {
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = MediaDetailFragmentBinding.inflate(inflater) binding = MediaDetailFragmentBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel viewLifecycleOwner.lifecycleScope.launch {
binding.item = detailViewModel.transformData(viewModel.item, resources) { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
bindActions(it) viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
bindState(it) Timber.d("$uiState")
when (uiState) {
is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading()
is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
}
} }
val seasonsAdapter = ViewItemListAdapter( val seasonsAdapter = ViewItemListAdapter(
fixedWidth = true, fixedWidth = true,
onClickListener = ViewItemListAdapter.OnClickListener {}) onClickListener = ViewItemListAdapter.OnClickListener {})
viewModel.seasons.observe(viewLifecycleOwner) {
seasonsAdapter.submitList(it)
binding.seasonTitle.isVisible = true
}
binding.seasonsRow.gridView.adapter = seasonsAdapter binding.seasonsRow.gridView.adapter = seasonsAdapter
binding.seasonsRow.gridView.verticalSpacing = 25 binding.seasonsRow.gridView.verticalSpacing = 25
@ -81,33 +83,110 @@ internal class MediaDetailFragment : Fragment() {
Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show()
} }
viewModel.actors.observe(viewLifecycleOwner) { cast ->
castAdapter.submitList(cast)
binding.castTitle.isVisible = cast.isNotEmpty()
}
binding.castRow.gridView.adapter = castAdapter binding.castRow.gridView.adapter = castAdapter
binding.castRow.gridView.verticalSpacing = 25 binding.castRow.gridView.verticalSpacing = 25
}
private fun bindState(state: MediaDetailViewModel.State) { playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
playerViewModel.onPlaybackRequested(lifecycleScope) { state -> when (playerItems) {
when (state) { is PlayerItemError -> bindPlayerItemsError(playerItems)
is PlayerItemError -> bindPlayerItemsError(state) is PlayerItems -> bindPlayerItems(playerItems)
is PlayerItems -> bindPlayerItems(state)
} }
} }
when (state.media) { binding.playButton.setOnClickListener {
is Movie -> binding.title.text = state.media.title binding.playButton.setImageResource(android.R.color.transparent)
is TvShow -> with(binding.subtitle) { binding.progressCircular.isVisible = true
binding.title.text = state.media.episode viewModel.item?.let { item ->
text = state.media.show playerViewModel.loadPlayerItems(item) {
isVisible = true VideoVersionDialogFragment(item, playerViewModel).show(
parentFragmentManager,
"videoversiondialog"
)
}
} }
} }
binding.trailerButton.setOnClickListener {
if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url)
)
startActivity(intent)
}
binding.checkButton.setOnClickListener {
when (viewModel.played) {
true -> {
viewModel.markAsUnplayed(args.itemId)
binding.checkButton.setImageResource(R.drawable.ic_check)
}
false -> {
viewModel.markAsPlayed(args.itemId)
binding.checkButton.setImageResource(R.drawable.ic_check_filled)
}
}
}
binding.favoriteButton.setOnClickListener {
when (viewModel.favorite) {
true -> {
viewModel.unmarkAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
}
false -> {
viewModel.markAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
}
}
}
binding.backButton.setOnClickListener { activity?.onBackPressed() }
} }
private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) {
uiState.apply {
binding.seasonTitle.isVisible = seasons.isNotEmpty()
val seasonsAdapter = binding.seasonsRow.gridView.adapter as ViewItemListAdapter
seasonsAdapter.submitList(seasons)
binding.castTitle.isVisible = actors.isNotEmpty()
val actorsAdapter = binding.castRow.gridView.adapter as PersonListAdapter
actorsAdapter.submitList(actors)
// Check icon
val checkDrawable = when (played) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
binding.checkButton.setImageResource(checkDrawable)
// Favorite icon
val favoriteDrawable = when (favorite) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
binding.favoriteButton.setImageResource(favoriteDrawable)
binding.title.text = item.name
binding.subtitle.text = item.seriesName
item.seriesName.let {
binding.subtitle.text = it
binding.subtitle.isVisible = true
}
binding.genres.text = genresString
binding.year.text = dateString
binding.playtime.text = runTime
binding.officialRating.text = item.officialRating
binding.communityRating.text = item.communityRating.toString()
binding.description.text = item.overview
bindBaseItemImage(binding.poster, item)
}
}
private fun bindUiStateLoading() {}
private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) {}
private fun bindPlayerItems(items: PlayerItems) { private fun bindPlayerItems(items: PlayerItems) {
navigateToPlayerActivity(items.items.toTypedArray()) navigateToPlayerActivity(items.items.toTypedArray())
binding.playButton.setImageDrawable( binding.playButton.setImageDrawable(
@ -132,59 +211,6 @@ internal class MediaDetailFragment : Fragment() {
binding.progressCircular.visibility = View.INVISIBLE binding.progressCircular.visibility = View.INVISIBLE
} }
private fun bindActions(state: MediaDetailViewModel.State) {
binding.playButton.setOnClickListener {
binding.progressCircular.isVisible = true
viewModel.item.value?.let { item ->
playerViewModel.loadPlayerItems(item) {
VideoVersionDialogFragment(item, playerViewModel).show(
parentFragmentManager,
"videoversiondialog"
)
}
}
}
if (state.trailerUrl != null) {
with(binding.trailerButton) {
isVisible = true
setOnClickListener { playTrailer(state.trailerUrl) }
}
} else {
binding.trailerButton.isVisible = false
}
if (state.isPlayed) {
with(binding.checkButton) {
setImageDrawable(resources.getDrawable(R.drawable.ic_check_filled))
setOnClickListener { viewModel.markAsUnplayed(args.itemId) }
}
} else {
with(binding.checkButton) {
setImageDrawable(resources.getDrawable(R.drawable.ic_check))
setOnClickListener { viewModel.markAsPlayed(args.itemId) }
}
}
if (state.isFavorite) {
with(binding.favoriteButton) {
setImageDrawable(resources.getDrawable(R.drawable.ic_heart_filled))
setOnClickListener { viewModel.unmarkAsFavorite(args.itemId) }
}
} else {
with(binding.favoriteButton) {
setImageDrawable(resources.getDrawable(R.drawable.ic_heart))
setOnClickListener { viewModel.markAsFavorite(args.itemId) }
}
}
binding.backButton.setOnClickListener { activity?.onBackPressed() }
}
private fun playTrailer(url: String) {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
private fun navigateToPlayerActivity( private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>, playerItems: Array<PlayerItem>,
) { ) {

View file

@ -1,8 +1,6 @@
package dev.jdtech.jellyfin.tv.ui package dev.jdtech.jellyfin.tv.ui
import android.content.res.Resources import android.content.res.Resources
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
@ -14,37 +12,35 @@ import javax.inject.Inject
internal class MediaDetailViewModel @Inject internal constructor() : ViewModel() { internal class MediaDetailViewModel @Inject internal constructor() : ViewModel() {
fun transformData( fun transformData(
data: LiveData<BaseItemDto>, data: BaseItemDto,
resources: Resources, resources: Resources,
transformed: (State) -> Unit transformed: (State) -> Unit
): LiveData<State> { ): State {
return Transformations.map(data) { baseItemDto -> return State(
State( dto = data,
dto = baseItemDto, description = data.overview.orEmpty(),
description = baseItemDto.overview.orEmpty(), year = data.productionYear.toString(),
year = baseItemDto.productionYear.toString(), officialRating = data.officialRating.orEmpty(),
officialRating = baseItemDto.officialRating.orEmpty(), communityRating = data.communityRating.toString(),
communityRating = baseItemDto.communityRating.toString(),
runtimeMinutes = String.format( runtimeMinutes = String.format(
resources.getString(R.string.runtime_minutes), resources.getString(R.string.runtime_minutes),
baseItemDto.runTimeTicks?.div(600_000_000) data.runTimeTicks?.div(600_000_000)
), ),
genres = baseItemDto.genres?.joinToString(" / ").orEmpty(), genres = data.genres?.joinToString(" / ").orEmpty(),
trailerUrl = baseItemDto.remoteTrailers?.firstOrNull()?.url, trailerUrl = data.remoteTrailers?.firstOrNull()?.url,
isPlayed = baseItemDto.userData?.played == true, isPlayed = data.userData?.played == true,
isFavorite = baseItemDto.userData?.isFavorite == true, isFavorite = data.userData?.isFavorite == true,
media = if (baseItemDto.type == MOVIE.type) { media = if (data.type == MOVIE.type) {
State.Movie( State.Movie(
title = baseItemDto.name.orEmpty() title = data.name.orEmpty()
) )
} else { } else {
State.TvShow( State.TvShow(
episode = baseItemDto.episodeTitle ?: baseItemDto.name.orEmpty(), episode = data.episodeTitle ?: data.name.orEmpty(),
show = baseItemDto.seriesName.orEmpty() show = data.seriesName.orEmpty()
) )
} }
).also(transformed) ).also(transformed)
}
} }
data class State( data class State(

View file

@ -5,7 +5,6 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import dev.jdtech.jellyfin.models.DownloadMetadata import dev.jdtech.jellyfin.models.DownloadMetadata
import dev.jdtech.jellyfin.models.DownloadRequestItem import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
@ -18,7 +17,7 @@ import java.util.UUID
var defaultStorage: File? = null var defaultStorage: File? = null
fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Fragment) { fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Context) {
val downloadRequest = DownloadManager.Request(uri) val downloadRequest = DownloadManager.Request(uri)
.setTitle(downloadRequestItem.metadata.name) .setTitle(downloadRequestItem.metadata.name)
.setDescription("Downloading") .setDescription("Downloading")
@ -32,7 +31,7 @@ fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context:
) )
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
if (!File(defaultStorage, downloadRequestItem.itemId.toString()).exists()) if (!File(defaultStorage, downloadRequestItem.itemId.toString()).exists())
downloadFile(downloadRequest, context.requireContext()) downloadFile(downloadRequest, context)
createMetadataFile( createMetadataFile(
downloadRequestItem.metadata, downloadRequestItem.metadata,
downloadRequestItem.itemId) downloadRequestItem.itemId)

View file

@ -1,68 +1,63 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.annotation.SuppressLint import androidx.lifecycle.*
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.jdtech.jellyfin.models.DownloadSection import dev.jdtech.jellyfin.models.DownloadSection
import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.* import java.util.*
class DownloadViewModel : ViewModel() { class DownloadViewModel : ViewModel() {
private val _downloadSections = MutableLiveData<List<DownloadSection>>() private val uiState = MutableStateFlow<UiState>(UiState.Loading)
val downloadSections: LiveData<List<DownloadSection>> = _downloadSections
private val _finishedLoading = MutableLiveData<Boolean>() sealed class UiState {
val finishedLoading: LiveData<Boolean> = _finishedLoading data class Normal(val downloadSections: List<DownloadSection>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _error = MutableLiveData<String>() fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
val error: LiveData<String> = _error scope.launch { uiState.collect { collector(it) } }
}
init { init {
loadData() loadData()
} }
@SuppressLint("ResourceType")
fun loadData() { fun loadData() {
_error.value = null
_finishedLoading.value = false
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
val items = loadDownloadedEpisodes() val items = loadDownloadedEpisodes()
if (items.isEmpty()) { if (items.isEmpty()) {
_downloadSections.value = listOf() uiState.emit(UiState.Normal(emptyList()))
_finishedLoading.value = true
return@launch return@launch
} }
val tempDownloadSections = mutableListOf<DownloadSection>() val downloadSections = mutableListOf<DownloadSection>()
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
DownloadSection(
UUID.randomUUID(),
"Episodes",
items.filter { it.metadata?.type == "Episode"}).let {
if (it.items.isNotEmpty()) tempDownloadSections.add(
it
)
}
DownloadSection( DownloadSection(
UUID.randomUUID(), UUID.randomUUID(),
"Movies", "Episodes",
items.filter { it.metadata?.type == "Movie" }).let { items.filter { it.metadata?.type == "Episode" }).let {
if (it.items.isNotEmpty()) tempDownloadSections.add( if (it.items.isNotEmpty()) downloadSections.add(
it it
) )
}
} }
_downloadSections.value = tempDownloadSections DownloadSection(
UUID.randomUUID(),
"Movies",
items.filter { it.metadata?.type == "Movie" }).let {
if (it.items.isNotEmpty()) downloadSections.add(
it
)
}
}
uiState.emit(UiState.Normal(downloadSections))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(UiState.Error(e.message))
_error.value = e.toString()
} }
_finishedLoading.value = true
} }
} }
} }

View file

@ -1,23 +1,18 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.app.Application
import android.net.Uri
import android.os.Build import android.os.Build
import androidx.lifecycle.LiveData import androidx.lifecycle.*
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.DownloadMetadata
import dev.jdtech.jellyfin.models.DownloadRequestItem import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata import dev.jdtech.jellyfin.utils.*
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode import kotlinx.coroutines.flow.MutableStateFlow
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto import kotlinx.coroutines.flow.collect
import dev.jdtech.jellyfin.utils.itemIsDownloaded
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.LocationType
import timber.log.Timber import timber.log.Timber
import java.text.DateFormat import java.text.DateFormat
import java.time.ZoneOffset import java.time.ZoneOffset
@ -29,91 +24,126 @@ import javax.inject.Inject
class EpisodeBottomSheetViewModel class EpisodeBottomSheetViewModel
@Inject @Inject
constructor( constructor(
private val application: Application,
private val jellyfinRepository: JellyfinRepository private val jellyfinRepository: JellyfinRepository
) : ViewModel() { ) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _item = MutableLiveData<BaseItemDto>() sealed class UiState {
val item: LiveData<BaseItemDto> = _item data class Normal(
val episode: BaseItemDto,
val runTime: String,
val dateString: String,
val played: Boolean,
val favorite: Boolean,
val downloaded: Boolean,
val downloadEpisode: Boolean,
) : UiState()
private val _runTime = MutableLiveData<String>() object Loading : UiState()
val runTime: LiveData<String> = _runTime data class Error(val message: String?) : UiState()
}
private val _dateString = MutableLiveData<String>() fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
val dateString: LiveData<String> = _dateString scope.launch { uiState.collect { collector(it) } }
}
private val _played = MutableLiveData<Boolean>()
val played: LiveData<Boolean> = _played
private val _favorite = MutableLiveData<Boolean>()
val favorite: LiveData<Boolean> = _favorite
private val _downloaded = MutableLiveData<Boolean>()
val downloaded: LiveData<Boolean> = _downloaded
private val _downloadEpisode = MutableLiveData<Boolean>()
val downloadEpisode: LiveData<Boolean> = _downloadEpisode
var item: BaseItemDto? = null
var runTime: String = ""
var dateString: String = ""
var played: Boolean = false
var favorite: Boolean = false
var downloaded: Boolean = false
var downloadEpisode: Boolean = false
var playerItems: MutableList<PlayerItem> = mutableListOf() var playerItems: MutableList<PlayerItem> = mutableListOf()
lateinit var downloadRequestItem: DownloadRequestItem lateinit var downloadRequestItem: DownloadRequestItem
fun loadEpisode(episodeId: UUID) { fun loadEpisode(episodeId: UUID) {
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
_downloaded.value = itemIsDownloaded(episodeId) val tempItem = jellyfinRepository.getItem(episodeId)
val item = jellyfinRepository.getItem(episodeId) item = tempItem
_item.value = item runTime = "${tempItem.runTimeTicks?.div(600000000)} min"
_runTime.value = "${item.runTimeTicks?.div(600000000)} min" dateString = getDateString(tempItem)
_dateString.value = getDateString(item) played = tempItem.userData?.played == true
_played.value = item.userData?.played favorite = tempItem.userData?.isFavorite == true
_favorite.value = item.userData?.isFavorite downloaded = itemIsDownloaded(episodeId)
uiState.emit(
UiState.Normal(
tempItem,
runTime,
dateString,
played,
favorite,
downloaded,
downloadEpisode
)
)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(UiState.Error(e.message))
} }
} }
} }
fun loadEpisode(playerItem : PlayerItem){ fun loadEpisode(playerItem: PlayerItem) {
playerItems.add(playerItem) viewModelScope.launch {
_item.value = downloadMetadataToBaseItemDto(playerItem.metadata!!) uiState.emit(UiState.Loading)
playerItems.add(playerItem)
item = downloadMetadataToBaseItemDto(playerItem.metadata!!)
uiState.emit(
UiState.Normal(
item!!,
runTime,
dateString,
played,
favorite,
downloaded,
downloadEpisode
)
)
}
} }
fun markAsPlayed(itemId: UUID) { fun markAsPlayed(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.markAsPlayed(itemId) jellyfinRepository.markAsPlayed(itemId)
} }
_played.value = true played = true
} }
fun markAsUnplayed(itemId: UUID) { fun markAsUnplayed(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.markAsUnplayed(itemId) jellyfinRepository.markAsUnplayed(itemId)
} }
_played.value = false played = false
} }
fun markAsFavorite(itemId: UUID) { fun markAsFavorite(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.markAsFavorite(itemId) jellyfinRepository.markAsFavorite(itemId)
} }
_favorite.value = true favorite = true
} }
fun unmarkAsFavorite(itemId: UUID) { fun unmarkAsFavorite(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.unmarkAsFavorite(itemId) jellyfinRepository.unmarkAsFavorite(itemId)
} }
_favorite.value = false favorite = false
} }
fun loadDownloadRequestItem(itemId: UUID) { fun loadDownloadRequestItem(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
loadEpisode(itemId) //loadEpisode(itemId)
val episode = _item.value val episode = item
val uri = jellyfinRepository.getStreamUrl(itemId, episode?.mediaSources?.get(0)?.id!!) val uri = jellyfinRepository.getStreamUrl(itemId, episode?.mediaSources?.get(0)?.id!!)
Timber.d(uri)
val metadata = baseItemDtoToDownloadMetadata(episode) val metadata = baseItemDtoToDownloadMetadata(episode)
downloadRequestItem = DownloadRequestItem(uri, itemId, metadata) downloadRequestItem = DownloadRequestItem(uri, itemId, metadata)
_downloadEpisode.value = true downloadEpisode = true
requestDownload(Uri.parse(downloadRequestItem.uri), downloadRequestItem, application)
} }
} }
@ -133,7 +163,7 @@ constructor(
} }
fun doneDownloadEpisode() { fun doneDownloadEpisode() {
_downloadEpisode.value = false downloadEpisode = false
_downloaded.value = true downloaded = true
} }
} }

View file

@ -1,16 +1,16 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LiveData import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.FavoriteSection import dev.jdtech.jellyfin.models.FavoriteSection
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@ -20,40 +20,41 @@ class FavoriteViewModel
constructor( constructor(
private val jellyfinRepository: JellyfinRepository private val jellyfinRepository: JellyfinRepository
) : ViewModel() { ) : ViewModel() {
private val _favoriteSections = MutableLiveData<List<FavoriteSection>>() private val uiState = MutableStateFlow<UiState>(UiState.Loading)
val favoriteSections: LiveData<List<FavoriteSection>> = _favoriteSections
private val _finishedLoading = MutableLiveData<Boolean>() sealed class UiState {
val finishedLoading: LiveData<Boolean> = _finishedLoading data class Normal(val favoriteSections: List<FavoriteSection>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _error = MutableLiveData<String>() fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
val error: LiveData<String> = _error scope.launch { uiState.collect { collector(it) } }
}
init { init {
loadData() loadData()
} }
fun loadData() { fun loadData() {
_error.value = null
_finishedLoading.value = false
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
val items = jellyfinRepository.getFavoriteItems() val items = jellyfinRepository.getFavoriteItems()
if (items.isEmpty()) { if (items.isEmpty()) {
_favoriteSections.value = listOf() uiState.emit(UiState.Normal(emptyList()))
_finishedLoading.value = true
return@launch return@launch
} }
val tempFavoriteSections = mutableListOf<FavoriteSection>() val favoriteSections = mutableListOf<FavoriteSection>()
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
FavoriteSection( FavoriteSection(
UUID.randomUUID(), UUID.randomUUID(),
"Movies", "Movies",
items.filter { it.type == "Movie" }).let { items.filter { it.type == "Movie" }).let {
if (it.items.isNotEmpty()) tempFavoriteSections.add( if (it.items.isNotEmpty()) favoriteSections.add(
it it
) )
} }
@ -61,7 +62,7 @@ constructor(
UUID.randomUUID(), UUID.randomUUID(),
"Shows", "Shows",
items.filter { it.type == "Series" }).let { items.filter { it.type == "Series" }).let {
if (it.items.isNotEmpty()) tempFavoriteSections.add( if (it.items.isNotEmpty()) favoriteSections.add(
it it
) )
} }
@ -69,18 +70,16 @@ constructor(
UUID.randomUUID(), UUID.randomUUID(),
"Episodes", "Episodes",
items.filter { it.type == "Episode" }).let { items.filter { it.type == "Episode" }).let {
if (it.items.isNotEmpty()) tempFavoriteSections.add( if (it.items.isNotEmpty()) favoriteSections.add(
it it
) )
} }
} }
_favoriteSections.value = tempFavoriteSections uiState.emit(UiState.Normal(favoriteSections))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(UiState.Error(e.message))
_error.value = e.toString()
} }
_finishedLoading.value = true
} }
} }
} }

View file

@ -2,8 +2,6 @@ package dev.jdtech.jellyfin.viewmodels
import android.app.Application import android.app.Application
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -17,62 +15,49 @@ import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.syncPlaybackProgress import dev.jdtech.jellyfin.utils.syncPlaybackProgress
import dev.jdtech.jellyfin.utils.toView import dev.jdtech.jellyfin.utils.toView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HomeViewModel @Inject internal constructor( class HomeViewModel @Inject internal constructor(
application: Application, private val application: Application,
private val repository: JellyfinRepository private val repository: JellyfinRepository
) : ViewModel() { ) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val views = MutableLiveData<List<HomeItem>>() sealed class UiState {
private val state = MutableSharedFlow<State>( data class Normal(val homeItems: List<HomeItem>) : UiState()
replay = 0, object Loading : UiState()
extraBufferCapacity = 1, data class Error(val message: String?) : UiState()
onBufferOverflow = BufferOverflow.DROP_OLDEST }
)
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
init { init {
loadData(updateCapabilities = true) loadData(updateCapabilities = true)
} }
private val continueWatchingString = application.resources.getString(R.string.continue_watching)
private val nextUpString = application.resources.getString(R.string.next_up)
fun views(): LiveData<List<HomeItem>> = views
fun onStateUpdate(
scope: LifecycleCoroutineScope,
collector: (State) -> Unit
) {
scope.launch { state.collect { collector(it) } }
}
fun refreshData() = loadData(updateCapabilities = false) fun refreshData() = loadData(updateCapabilities = false)
private fun loadData(updateCapabilities: Boolean) { private fun loadData(updateCapabilities: Boolean) {
state.tryEmit(Loading(inProgress = true))
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
if (updateCapabilities) repository.postCapabilities() if (updateCapabilities) repository.postCapabilities()
val updated = loadDynamicItems() + loadViews() val updated = loadDynamicItems() + loadViews()
views.postValue(updated)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
syncPlaybackProgress(repository) syncPlaybackProgress(repository)
} }
state.tryEmit(Loading(inProgress = false)) uiState.emit(UiState.Normal(updated))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(UiState.Error(e.message))
state.tryEmit(LoadingError(e.toString()))
} }
} }
} }
@ -83,11 +68,11 @@ class HomeViewModel @Inject internal constructor(
val items = mutableListOf<HomeSection>() val items = mutableListOf<HomeSection>()
if (resumeItems.isNotEmpty()) { if (resumeItems.isNotEmpty()) {
items.add(HomeSection(continueWatchingString, resumeItems)) items.add(HomeSection(application.resources.getString(R.string.continue_watching), resumeItems))
} }
if (nextUpItems.isNotEmpty()) { if (nextUpItems.isNotEmpty()) {
items.add(HomeSection(nextUpString, nextUpItems)) items.add(HomeSection(application.resources.getString(R.string.next_up), nextUpItems))
} }
items.map { Section(it) } items.map { Section(it) }
@ -102,11 +87,6 @@ class HomeViewModel @Inject internal constructor(
.map { (view, latest) -> view.toView().apply { items = latest } } .map { (view, latest) -> view.toView().apply { items = latest } }
.map { ViewItem(it) } .map { ViewItem(it) }
} }
sealed class State
data class LoadingError(val message: String) : State()
data class Loading(val inProgress: Boolean) : State()
} }

View file

@ -4,6 +4,8 @@ import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.SortBy import dev.jdtech.jellyfin.utils.SortBy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
@ -14,16 +16,20 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LibraryViewModel class LibraryViewModel
@Inject @Inject
constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { constructor(
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _items = MutableLiveData<List<BaseItemDto>>() sealed class UiState {
val items: LiveData<List<BaseItemDto>> = _items data class Normal(val items: List<BaseItemDto>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _finishedLoading = MutableLiveData<Boolean>() fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
val finishedLoading: LiveData<Boolean> = _finishedLoading scope.launch { uiState.collect { collector(it) } }
}
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
fun loadItems( fun loadItems(
parentId: UUID, parentId: UUID,
@ -31,8 +37,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
sortBy: SortBy = SortBy.defaultValue, sortBy: SortBy = SortBy.defaultValue,
sortOrder: SortOrder = SortOrder.ASCENDING sortOrder: SortOrder = SortOrder.ASCENDING
) { ) {
_error.value = null
_finishedLoading.value = false
Timber.d("$libraryType") Timber.d("$libraryType")
val itemType = when (libraryType) { val itemType = when (libraryType) {
"movies" -> "Movie" "movies" -> "Movie"
@ -40,19 +44,19 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
else -> null else -> null
} }
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
_items.value = jellyfinRepository.getItems( val items = jellyfinRepository.getItems(
parentId, parentId,
includeTypes = if (itemType != null) listOf(itemType) else null, includeTypes = if (itemType != null) listOf(itemType) else null,
recursive = true, recursive = true,
sortBy = sortBy, sortBy = sortBy,
sortOrder = sortOrder sortOrder = sortOrder
) )
uiState.emit(UiState.Normal(items))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(UiState.Error(e.message))
_error.value = e.toString()
} }
_finishedLoading.value = true
} }
} }
} }

View file

@ -1,8 +1,9 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.app.Application
import android.net.Uri
import android.os.Build import android.os.Build
import androidx.lifecycle.LiveData import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -13,7 +14,10 @@ import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
import dev.jdtech.jellyfin.utils.itemIsDownloaded import dev.jdtech.jellyfin.utils.itemIsDownloaded
import dev.jdtech.jellyfin.utils.requestDownload
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
@ -25,92 +29,134 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MediaInfoViewModel class MediaInfoViewModel
@Inject @Inject
constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { constructor(
private val application: Application,
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _item = MutableLiveData<BaseItemDto>() sealed class UiState {
val item: LiveData<BaseItemDto> = _item data class Normal(
val item: BaseItemDto,
val actors: List<BaseItemPerson>,
val director: BaseItemPerson?,
val writers: List<BaseItemPerson>,
val writersString: String,
val genresString: String,
val runTime: String,
val dateString: String,
val nextUp: BaseItemDto?,
val seasons: List<BaseItemDto>,
val played: Boolean,
val favorite: Boolean,
val downloaded: Boolean,
) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _actors = MutableLiveData<List<BaseItemPerson>>() fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
val actors: LiveData<List<BaseItemPerson>> = _actors scope.launch { uiState.collect { collector(it) } }
}
private val _director = MutableLiveData<BaseItemPerson>() var item: BaseItemDto? = null
val director: LiveData<BaseItemPerson> = _director private var actors: List<BaseItemPerson> = emptyList()
private var director: BaseItemPerson? = null
private var writers: List<BaseItemPerson> = emptyList()
private var writersString: String = ""
private var genresString: String = ""
private var runTime: String = ""
private var dateString: String = ""
var nextUp: BaseItemDto? = null
var seasons: List<BaseItemDto> = emptyList()
var played: Boolean = false
var favorite: Boolean = false
private var downloaded: Boolean = false
private var downloadMedia: Boolean = false
private val _writers = MutableLiveData<List<BaseItemPerson>>() private lateinit var downloadRequestItem: DownloadRequestItem
val writers: LiveData<List<BaseItemPerson>> = _writers
private val _writersString = MutableLiveData<String>()
val writersString: LiveData<String> = _writersString
private val _genresString = MutableLiveData<String>()
val genresString: LiveData<String> = _genresString
private val _runTime = MutableLiveData<String>()
val runTime: LiveData<String> = _runTime
private val _dateString = MutableLiveData<String>()
val dateString: LiveData<String> = _dateString
private val _nextUp = MutableLiveData<BaseItemDto>()
val nextUp: LiveData<BaseItemDto> = _nextUp
private val _seasons = MutableLiveData<List<BaseItemDto>>()
val seasons: LiveData<List<BaseItemDto>> = _seasons
private val _played = MutableLiveData<Boolean>()
val played: LiveData<Boolean> = _played
private val _favorite = MutableLiveData<Boolean>()
val favorite: LiveData<Boolean> = _favorite
private val _downloaded = MutableLiveData<Boolean>()
val downloaded: LiveData<Boolean> = _downloaded
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 lateinit var playerItem: PlayerItem
fun loadData(itemId: UUID, itemType: String) { fun loadData(itemId: UUID, itemType: String) {
_error.value = null
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
_downloaded.value = itemIsDownloaded(itemId) val tempItem = jellyfinRepository.getItem(itemId)
_item.value = jellyfinRepository.getItem(itemId) item = tempItem
_actors.value = getActors(_item.value!!) actors = getActors(tempItem)
_director.value = getDirector(_item.value!!) director = getDirector(tempItem)
_writers.value = getWriters(_item.value!!) writers = getWriters(tempItem)
_writersString.value = writersString = writers.joinToString(separator = ", ") { it.name.toString() }
_writers.value?.joinToString(separator = ", ") { it.name.toString() } genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
_genresString.value = _item.value?.genres?.joinToString(separator = ", ") runTime = "${tempItem.runTimeTicks?.div(600000000)} min"
_runTime.value = "${_item.value?.runTimeTicks?.div(600000000)} min" dateString = getDateString(tempItem)
_dateString.value = getDateString(_item.value!!) played = tempItem.userData?.played ?: false
_played.value = _item.value?.userData?.played favorite = tempItem.userData?.isFavorite ?: false
_favorite.value = _item.value?.userData?.isFavorite downloaded = itemIsDownloaded(itemId)
if (itemType == "Series" || itemType == "Episode") { if (itemType == "Series") {
_nextUp.value = getNextUp(itemId) nextUp = getNextUp(itemId)
_seasons.value = jellyfinRepository.getSeasons(itemId) seasons = jellyfinRepository.getSeasons(itemId)
} }
uiState.emit(UiState.Normal(
tempItem,
actors,
director,
writers,
writersString,
genresString,
runTime,
dateString,
nextUp,
seasons,
played,
favorite,
downloaded
))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.d(e)
_error.value = e.toString() Timber.d(itemId.toString())
uiState.emit(UiState.Error(e.message))
} }
} }
} }
fun loadData(playerItem: PlayerItem) { fun loadData(pItem: PlayerItem) {
this.playerItem = playerItem viewModelScope.launch {
_item.value = downloadMetadataToBaseItemDto(playerItem.metadata!!) playerItem = pItem
val tempItem = downloadMetadataToBaseItemDto(playerItem.metadata!!)
item = tempItem
actors = getActors(tempItem)
director = getDirector(tempItem)
writers = getWriters(tempItem)
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
runTime = ""
dateString = ""
played = tempItem.userData?.played ?: false
favorite = tempItem.userData?.isFavorite ?: false
uiState.emit(UiState.Normal(
tempItem,
actors,
director,
writers,
writersString,
genresString,
runTime,
dateString,
nextUp,
seasons,
played,
favorite,
downloaded
))
}
} }
private suspend fun getActors(item: BaseItemDto): List<BaseItemPerson>? { private suspend fun getActors(item: BaseItemDto): List<BaseItemPerson> {
val actors: List<BaseItemPerson>? val actors: List<BaseItemPerson>
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
actors = item.people?.filter { it.type == "Actor" } actors = item.people?.filter { it.type == "Actor" } ?: emptyList()
} }
return actors return actors
} }
@ -123,10 +169,10 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
return director return director
} }
private suspend fun getWriters(item: BaseItemDto): List<BaseItemPerson>? { private suspend fun getWriters(item: BaseItemDto): List<BaseItemPerson> {
val writers: List<BaseItemPerson>? val writers: List<BaseItemPerson>
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
writers = item.people?.filter { it.type == "Writer" } writers = item.people?.filter { it.type == "Writer" } ?: emptyList()
} }
return writers return writers
} }
@ -144,28 +190,28 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.markAsPlayed(itemId) jellyfinRepository.markAsPlayed(itemId)
} }
_played.value = true played = true
} }
fun markAsUnplayed(itemId: UUID) { fun markAsUnplayed(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.markAsUnplayed(itemId) jellyfinRepository.markAsUnplayed(itemId)
} }
_played.value = false played = false
} }
fun markAsFavorite(itemId: UUID) { fun markAsFavorite(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.markAsFavorite(itemId) jellyfinRepository.markAsFavorite(itemId)
} }
_favorite.value = true favorite = true
} }
fun unmarkAsFavorite(itemId: UUID) { fun unmarkAsFavorite(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.unmarkAsFavorite(itemId) jellyfinRepository.unmarkAsFavorite(itemId)
} }
_favorite.value = false favorite = false
} }
private fun getDateString(item: BaseItemDto): String { private fun getDateString(item: BaseItemDto): String {
@ -191,21 +237,17 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
fun loadDownloadRequestItem(itemId: UUID) { fun loadDownloadRequestItem(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
val downloadItem = _item.value val downloadItem = item
val uri = val uri =
jellyfinRepository.getStreamUrl(itemId, downloadItem?.mediaSources?.get(0)?.id!!) jellyfinRepository.getStreamUrl(itemId, downloadItem?.mediaSources?.get(0)?.id!!)
val metadata = baseItemDtoToDownloadMetadata(downloadItem) val metadata = baseItemDtoToDownloadMetadata(downloadItem)
downloadRequestItem = DownloadRequestItem(uri, itemId, metadata) downloadRequestItem = DownloadRequestItem(uri, itemId, metadata)
_downloadMedia.value = true downloadMedia = true
requestDownload(Uri.parse(downloadRequestItem.uri), downloadRequestItem, application)
} }
} }
fun deleteItem() { fun deleteItem() {
deleteDownloadedEpisode(playerItem.mediaSourceUri) deleteDownloadedEpisode(playerItem.mediaSourceUri)
} }
fun doneDownloadMedia() {
_downloadMedia.value = false
_downloaded.value = true
}
} }

View file

@ -2,10 +2,12 @@ package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.* import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.unsupportedCollections
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -15,38 +17,35 @@ constructor(
private val jellyfinRepository: JellyfinRepository private val jellyfinRepository: JellyfinRepository
) : ViewModel() { ) : ViewModel() {
private val _collections = MutableLiveData<List<BaseItemDto>>() private val uiState = MutableStateFlow<UiState>(UiState.Loading)
val collections: LiveData<List<BaseItemDto>> = _collections
private val _finishedLoading = MutableLiveData<Boolean>() sealed class UiState {
val finishedLoading: LiveData<Boolean> = _finishedLoading data class Normal(val collections: List<BaseItemDto>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _error = MutableLiveData<String>() fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
val error: LiveData<String> = _error scope.launch { uiState.collect { collector(it) } }
}
init { init {
loadData() loadData()
} }
fun loadData() { fun loadData() {
_finishedLoading.value = false
_error.value = null
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
val items = jellyfinRepository.getItems() val items = jellyfinRepository.getItems()
_collections.value = val collections =
items.filter { items.filter { collection -> unsupportedCollections().none { it.type == collection.collectionType } }
it.collectionType != "homevideos" && uiState.emit(UiState.Normal(collections))
it.collectionType != "music" &&
it.collectionType != "playlists" &&
it.collectionType != "boxsets" &&
it.collectionType != "books"
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(
_error.value = e.toString() UiState.Error(e.message)
)
} }
_finishedLoading.value = true
} }
} }
} }

View file

@ -1,7 +1,6 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LiveData import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -9,9 +8,10 @@ import dev.jdtech.jellyfin.models.ContentType.MOVIE
import dev.jdtech.jellyfin.models.ContentType.TVSHOW import dev.jdtech.jellyfin.models.ContentType.TVSHOW
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.contentType import dev.jdtech.jellyfin.utils.contentType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import java.lang.Exception import java.lang.Exception
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -21,29 +21,30 @@ internal class PersonDetailViewModel @Inject internal constructor(
private val jellyfinRepository: JellyfinRepository private val jellyfinRepository: JellyfinRepository
) : ViewModel() { ) : ViewModel() {
val data = MutableLiveData<PersonOverview>() private val uiState = MutableStateFlow<UiState>(UiState.Loading)
val starredIn = MutableLiveData<StarredIn>()
private val _finishedLoading = MutableLiveData<Boolean>() sealed class UiState {
val finishedLoading: LiveData<Boolean> = _finishedLoading data class Normal(val data: PersonOverview, val starredIn: StarredIn) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _error = MutableLiveData<String>() fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
val error: LiveData<String> = _error scope.launch { uiState.collect { collector(it) } }
}
fun loadData(personId: UUID) { fun loadData(personId: UUID) {
_error.value = null
_finishedLoading.value = false
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
val personDetail = jellyfinRepository.getItem(personId) val personDetail = jellyfinRepository.getItem(personId)
data.postValue( val data = PersonOverview(
PersonOverview( name = personDetail.name.orEmpty(),
name = personDetail.name.orEmpty(), overview = personDetail.overview.orEmpty(),
overview = personDetail.overview.orEmpty(), dto = personDetail
dto = personDetail
)
) )
val items = jellyfinRepository.getPersonItems( val items = jellyfinRepository.getPersonItems(
personIds = listOf(personId), personIds = listOf(personId),
includeTypes = listOf(MOVIE, TVSHOW), includeTypes = listOf(MOVIE, TVSHOW),
@ -53,13 +54,12 @@ internal class PersonDetailViewModel @Inject internal constructor(
val movies = items.filter { it.contentType() == MOVIE } val movies = items.filter { it.contentType() == MOVIE }
val shows = items.filter { it.contentType() == TVSHOW } val shows = items.filter { it.contentType() == TVSHOW }
starredIn.postValue(StarredIn(movies, shows)) val starredIn = StarredIn(movies, shows)
uiState.emit(UiState.Normal(data, starredIn))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(UiState.Error(e.message))
_error.value = e.toString()
} }
_finishedLoading.value = true
} }
} }

View file

@ -1,16 +1,16 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LiveData import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.FavoriteSection import dev.jdtech.jellyfin.models.FavoriteSection
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@ -20,36 +20,37 @@ class SearchResultViewModel
constructor( constructor(
private val jellyfinRepository: JellyfinRepository private val jellyfinRepository: JellyfinRepository
) : ViewModel() { ) : ViewModel() {
private val _sections = MutableLiveData<List<FavoriteSection>>() private val uiState = MutableStateFlow<UiState>(UiState.Loading)
val sections: LiveData<List<FavoriteSection>> = _sections
private val _finishedLoading = MutableLiveData<Boolean>() sealed class UiState {
val finishedLoading: LiveData<Boolean> = _finishedLoading data class Normal(val sections: List<FavoriteSection>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _error = MutableLiveData<String>() fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
val error: LiveData<String> = _error scope.launch { uiState.collect { collector(it) } }
}
fun loadData(query: String) { fun loadData(query: String) {
_error.value = null
_finishedLoading.value = false
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
val items = jellyfinRepository.getSearchItems(query) val items = jellyfinRepository.getSearchItems(query)
if (items.isEmpty()) { if (items.isEmpty()) {
_sections.value = listOf() uiState.emit(UiState.Normal(emptyList()))
_finishedLoading.value = true
return@launch return@launch
} }
val tempSections = mutableListOf<FavoriteSection>() val sections = mutableListOf<FavoriteSection>()
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
FavoriteSection( FavoriteSection(
UUID.randomUUID(), UUID.randomUUID(),
"Movies", "Movies",
items.filter { it.type == "Movie" }).let { items.filter { it.type == "Movie" }).let {
if (it.items.isNotEmpty()) tempSections.add( if (it.items.isNotEmpty()) sections.add(
it it
) )
} }
@ -57,7 +58,7 @@ constructor(
UUID.randomUUID(), UUID.randomUUID(),
"Shows", "Shows",
items.filter { it.type == "Series" }).let { items.filter { it.type == "Series" }).let {
if (it.items.isNotEmpty()) tempSections.add( if (it.items.isNotEmpty()) sections.add(
it it
) )
} }
@ -65,18 +66,16 @@ constructor(
UUID.randomUUID(), UUID.randomUUID(),
"Episodes", "Episodes",
items.filter { it.type == "Episode" }).let { items.filter { it.type == "Episode" }).let {
if (it.items.isNotEmpty()) tempSections.add( if (it.items.isNotEmpty()) sections.add(
it it
) )
} }
} }
_sections.value = tempSections uiState.emit(UiState.Normal(sections))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(UiState.Error(e.message))
_error.value = e.toString()
} }
_finishedLoading.value = true
} }
} }
} }

View file

@ -1,48 +1,49 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LiveData import androidx.lifecycle.*
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.adapters.EpisodeItem import dev.jdtech.jellyfin.adapters.EpisodeItem
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import timber.log.Timber
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SeasonViewModel class SeasonViewModel
@Inject @Inject
constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { constructor(
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _episodes = MutableLiveData<List<EpisodeItem>>() sealed class UiState {
val episodes: LiveData<List<EpisodeItem>> = _episodes data class Normal(val episodes: List<EpisodeItem>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _finishedLoading = MutableLiveData<Boolean>() fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
val finishedLoading: LiveData<Boolean> = _finishedLoading scope.launch { uiState.collect { collector(it) } }
}
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
fun loadEpisodes(seriesId: UUID, seasonId: UUID) { fun loadEpisodes(seriesId: UUID, seasonId: UUID) {
_error.value = null
_finishedLoading.value = false
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
_episodes.value = getEpisodes(seriesId, seasonId) val episodes = getEpisodes(seriesId, seasonId)
uiState.emit(UiState.Normal(episodes))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(UiState.Error(e.message))
_error.value = e.toString()
} }
_finishedLoading.value = true
} }
} }
private suspend fun getEpisodes(seriesId: UUID, seasonId: UUID): List<EpisodeItem> { private suspend fun getEpisodes(seriesId: UUID, seasonId: UUID): List<EpisodeItem> {
val episodes = jellyfinRepository.getEpisodes(seriesId, seasonId, fields = listOf(ItemFields.OVERVIEW)) val episodes =
jellyfinRepository.getEpisodes(seriesId, seasonId, fields = listOf(ItemFields.OVERVIEW))
return listOf(EpisodeItem.Header) + episodes.map { EpisodeItem.Episode(it) } return listOf(EpisodeItem.Header) + episodes.map { EpisodeItem.Episode(it) }
} }
} }

View file

@ -1,8 +1,7 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.lifecycle.LiveData import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -10,6 +9,9 @@ import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.database.ServerDatabaseDao import dev.jdtech.jellyfin.database.ServerDatabaseDao
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.* import java.util.*
@ -23,12 +25,17 @@ constructor(
private val jellyfinApi: JellyfinApi, private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao, private val database: ServerDatabaseDao,
) : ViewModel() { ) : ViewModel() {
val servers = database.getAllServers()
private val _servers = database.getAllServers() private val navigateToMain = MutableSharedFlow<Boolean>(
val servers: LiveData<List<Server>> = _servers replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val _navigateToMain = MutableLiveData<Boolean>() fun onNavigateToMain(scope: LifecycleCoroutineScope, collector: (Boolean) -> Unit) {
val navigateToMain: LiveData<Boolean> = _navigateToMain scope.launch { navigateToMain.collect { collector(it) } }
}
/** /**
* Delete server from database * Delete server from database
@ -54,10 +61,6 @@ constructor(
userId = UUID.fromString(server.userId) userId = UUID.fromString(server.userId)
} }
_navigateToMain.value = true navigateToMain.tryEmit(true)
}
fun doneNavigatingToMain() {
_navigateToMain.value = false
} }
} }

View file

@ -1,297 +1,250 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingDefaultResource" android:layout_width="match_parent"
> android:layout_height="match_parent"
tools:ignore="MissingDefaultResource">
<data> <include
android:id="@+id/error_layout"
layout="@layout/error_panel"
tools:visibility="gone" />
<import type="android.view.View" /> <ScrollView
<variable
name="item"
type="androidx.lifecycle.LiveData&lt;dev.jdtech.jellyfin.tv.ui.MediaDetailViewModel.State&gt;"
/>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel"
/>
</data>
<FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="wrap_content">
>
<include <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/error_layout"
layout="@layout/error_panel"
tools:visibility="gone"
/>
<ScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
>
<androidx.constraintlayout.widget.ConstraintLayout <ImageButton
android:id="@+id/back_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_exit"
android:focusable="true"
android:padding="16dp"
android:src="@drawable/ic_arrow_left"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
android:paddingBottom="16dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintStart_toEndOf="@id/back_button"
app:layout_constraintTop_toTopOf="parent"
tools:text="Alita: Battle Angel" />
<TextClock
android:id="@+id/clock"
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:gravity="center_vertical"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="12:00" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/poster"
android:layout_width="320dp"
android:layout_height="180dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
<TextView
android:id="@+id/subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/title"
tools:text="Subtitle" />
<TextView
android:id="@+id/genres"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/subtitle"
tools:text="Action, Science Fiction, Adventure" />
<TextView
android:id="@+id/year"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="2019" />
<TextView
android:id="@+id/playtime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/year"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="122 min" />
<TextView
android:id="@+id/official_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/playtime"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="PG-13" />
<TextView
android:id="@+id/community_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:drawablePadding="4dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:drawableStartCompat="@drawable/ic_star"
app:drawableTint="@color/yellow"
app:layout_constraintStart_toEndOf="@id/official_rating"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="7.3" />
<TextView
android:id="@+id/description"
android:layout_width="400dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginBottom="24dp"
android:ellipsize="end"
android:maxLines="5"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/year"
tools:text="An angel falls. A warrior rises. When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past." />
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/play_button"
android:layout_width="72dp"
android:layout_height="48dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:contentDescription="@string/play_button_description"
android:focusable="true"
android:paddingHorizontal="24dp"
android:paddingVertical="12dp"
android:src="@drawable/ic_play"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/trailer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:contentDescription="@string/trailer_button_description"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_film"
app:layout_constraintStart_toEndOf="@id/play_button"
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/check_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:contentDescription="@string/check_button_description"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_check"
app:layout_constraintStart_toEndOf="@id/trailer_button"
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/favorite_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/favorite_button_description"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_heart"
app:layout_constraintStart_toEndOf="@id/check_button"
app:layout_constraintTop_toBottomOf="@id/description" />
<TextView
android:id="@+id/season_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginTop="32dp"
android:text="@string/seasons"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/play_button" />
<androidx.leanback.widget.ListRowView
android:id="@+id/seasons_row"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/season_title" />
<ImageButton <TextView
android:id="@+id/back_button" android:id="@+id/cast_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/transparent_circle_background" android:layout_marginStart="@dimen/horizontal_margin"
android:contentDescription="@string/player_controls_exit" android:layout_marginTop="16dp"
android:padding="16dp" android:text="@string/cast_amp_crew"
android:src="@drawable/ic_arrow_left" android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
android:focusable="true" android:visibility="gone"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toBottomOf="@id/seasons_row" />
/>
<TextView <androidx.leanback.widget.ListRowView
android:id="@+id/title" android:id="@+id/cast_row"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" app:layout_constraintStart_toStartOf="parent"
android:layout_marginTop="8dp" app:layout_constraintTop_toBottomOf="@id/cast_title" />
android:paddingBottom="16dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintStart_toEndOf="@id/back_button"
app:layout_constraintTop_toTopOf="parent"
tools:text="Alita: Battle Angel"
/>
<TextClock </androidx.constraintlayout.widget.ConstraintLayout>
android:id="@+id/clock"
android:layout_width="wrap_content"
android:layout_height="24dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:gravity="center_vertical"
android:textSize="18sp"
android:layout_marginEnd="24dp"
tools:text="12:00"
/>
<com.google.android.material.imageview.ShapeableImageView </ScrollView>
android:id="@+id/poster"
android:layout_width="320dp"
android:layout_height="180dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:scaleType="centerCrop"
app:baseItemImage="@{item.dto}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title"
/>
<TextView </FrameLayout>
android:id="@+id/subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/title"
android:visibility="gone"
tools:text="Subtitle"
/>
<TextView
android:id="@+id/genres"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:text="@{item.genres}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/subtitle"
tools:text="Action, Science Fiction, Adventure"
/>
<TextView
android:id="@+id/year"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
app:layout_constraintTop_toBottomOf="@id/genres"
app:layout_constraintStart_toEndOf="@id/poster"
android:layout_marginEnd="8dp"
android:text="@{item.year}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="2019" />
<TextView
android:id="@+id/playtime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
app:layout_constraintTop_toBottomOf="@id/genres"
app:layout_constraintStart_toEndOf="@id/year"
android:layout_marginEnd="8dp"
android:text="@{item.runtimeMinutes}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="122 min" />
<TextView
android:id="@+id/official_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
app:layout_constraintTop_toBottomOf="@id/genres"
app:layout_constraintStart_toEndOf="@id/playtime"
android:layout_marginEnd="8dp"
android:text="@{item.officialRating}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="PG-13" />
<TextView
android:id="@+id/community_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
app:layout_constraintTop_toBottomOf="@id/genres"
app:layout_constraintStart_toEndOf="@id/official_rating"
android:drawablePadding="4dp"
android:text="@{item.communityRating}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:drawableStart="@drawable/ic_star"
android:drawableTint="@color/yellow"
tools:text="7.3" />
<TextView
android:id="@+id/description"
android:layout_width="400dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:text="@{item.description}"
android:maxLines="5"
android:ellipsize="end"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/year"
tools:text="An angel falls. A warrior rises. When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past."
/>
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="@dimen/horizontal_margin"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/description"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible" />
<ImageButton
android:id="@+id/play_button"
android:layout_width="72dp"
android:layout_height="48dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:contentDescription="@string/play_button_description"
android:paddingHorizontal="24dp"
android:paddingVertical="12dp"
android:src="@drawable/ic_play"
android:focusable="true"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/description"
/>
<ImageButton
android:id="@+id/trailer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:contentDescription="@string/trailer_button_description"
android:padding="12dp"
android:src="@drawable/ic_film"
android:focusable="true"
app:layout_constraintStart_toEndOf="@id/play_button"
app:layout_constraintTop_toBottomOf="@id/description"
/>
<ImageButton
android:id="@+id/check_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:contentDescription="@string/check_button_description"
android:padding="12dp"
android:focusable="true"
android:src="@drawable/ic_check"
app:layout_constraintStart_toEndOf="@id/trailer_button"
app:layout_constraintTop_toBottomOf="@id/description"
/>
<ImageButton
android:id="@+id/favorite_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
android:focusable="true"
android:src="@drawable/ic_heart"
android:contentDescription="@string/favorite_button_description"
app:layout_constraintStart_toEndOf="@id/check_button"
app:layout_constraintTop_toBottomOf="@id/description"
/>
<TextView
android:id="@+id/season_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
app:layout_constraintTop_toBottomOf="@id/play_button"
app:layout_constraintStart_toStartOf="parent"
android:text="@string/seasons"
android:visibility="gone"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
/>
<androidx.leanback.widget.ListRowView
android:id="@+id/seasons_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/season_title"
app:layout_constraintStart_toStartOf="parent"
/>
<TextView
android:id="@+id/cast_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/seasons_row"
app:layout_constraintStart_toStartOf="parent"
android:text="@string/cast_amp_crew"
android:visibility="gone"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
/>
<androidx.leanback.widget.ListRowView
android:id="@+id/cast_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/cast_title"
app:layout_constraintStart_toStartOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</FrameLayout>
</layout>

View file

@ -1,259 +1,265 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="24dp">
<data> <com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<import type="android.view.View" /> <ImageView
android:id="@+id/holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:importantForAccessibility="no"
android:src="@drawable/ic_minus_fat"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?attr/colorOnSurface" />
<import type="org.jellyfin.sdk.model.api.LocationType" /> <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/episode_image"
android:layout_width="142dp"
android:layout_height="85dp"
android:layout_marginStart="24dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/holder"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Findroid.Image" />
<variable <FrameLayout
name="viewModel" android:id="@+id/missing_icon"
type="dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel" /> android:layout_width="24dp"
</data> android:layout_height="24dp"
android:layout_marginTop="8dp"
<androidx.constraintlayout.widget.ConstraintLayout android:layout_marginEnd="8dp"
android:layout_width="match_parent" android:background="@drawable/circle_background"
android:layout_height="match_parent" android:backgroundTint="?attr/colorError"
android:paddingBottom="24dp"> app:layout_constraintEnd_toEndOf="@id/episode_image"
app:layout_constraintTop_toTopOf="@id/episode_image"
<ImageView android:visibility="gone"
android:id="@+id/holder" tools:visibility="visible">
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:importantForAccessibility="no"
android:src="@drawable/ic_minus_fat"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?attr/colorOnSurface" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/episode_image"
android:layout_width="142dp"
android:layout_height="85dp"
android:layout_marginStart="24dp"
android:scaleType="centerCrop"
app:baseItemImage="@{viewModel.item}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/holder"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Findroid.Image" />
<FrameLayout
android:id="@+id/missing_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/circle_background"
android:backgroundTint="?attr/colorError"
android:visibility="@{viewModel.item.locationType == LocationType.VIRTUAL ? View.VISIBLE : View.GONE}"
app:layout_constraintEnd_toEndOf="@id/episode_image"
app:layout_constraintTop_toTopOf="@id/episode_image">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="M"
android:textColor="@color/white"
tools:ignore="HardcodedText" />
</FrameLayout>
<FrameLayout
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="4dp"
android:layout_marginHorizontal="8dp"
android:layout_marginBottom="8dp"
android:background="@drawable/button_setup_background"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/episode_image"
app:layout_constraintStart_toStartOf="@id/episode_image"
tools:layout_width="50dp"
tools:visibility="visible" />
<TextView <TextView
android:id="@+id/episode_name" android:layout_width="match_parent"
android:layout_width="0dp" android:layout_height="match_parent"
android:gravity="center"
android:text="M"
android:textColor="@color/white"
tools:ignore="HardcodedText" />
</FrameLayout>
<FrameLayout
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="4dp"
android:layout_marginHorizontal="8dp"
android:layout_marginBottom="8dp"
android:background="@drawable/button_setup_background"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/episode_image"
app:layout_constraintStart_toStartOf="@id/episode_image"
tools:layout_width="50dp"
tools:visibility="visible" />
<TextView
android:id="@+id/episode_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="24dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/episode_image"
app:layout_constraintTop_toTopOf="@id/episode_image"
tools:text="1. To You, in 2000 Years: The Fall of Shiganshina, Part 1" />
<LinearLayout
android:id="@+id/episode_metadata"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="24dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/episode_image"
app:layout_constraintTop_toBottomOf="@id/episode_image">
<TextView
android:id="@+id/year"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="12dp" android:layout_marginEnd="8dp"
android:layout_marginEnd="24dp" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:text="@{String.format(@string/episode_name_extended, viewModel.item.parentIndexNumber, viewModel.item.indexNumber, viewModel.item.name)}" tools:text="4/6/2013" />
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/episode_image"
app:layout_constraintTop_toTopOf="@id/episode_image"
tools:text="1. To You, in 2000 Years: The Fall of Shiganshina, Part 1" />
<LinearLayout <TextView
android:id="@+id/episode_metadata" android:id="@+id/playtime"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginEnd="8dp"
android:layout_marginEnd="24dp" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintEnd_toEndOf="parent" tools:text="26 min" />
app:layout_constraintStart_toStartOf="@id/episode_image"
app:layout_constraintTop_toBottomOf="@id/episode_image">
<TextView
<TextView android:id="@+id/community_rating"
android:id="@+id/year" android:layout_width="wrap_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@{viewModel.dateString}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="4/6/2013" />
<TextView
android:id="@+id/playtime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@{viewModel.runTime}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="26 min" />
<TextView
android:id="@+id/community_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="4dp"
android:gravity="bottom"
android:text="@{viewModel.item.communityRating.toString()}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:drawableStartCompat="@drawable/ic_star"
app:drawableTint="@color/yellow"
tools:text="8.8" />
</LinearLayout>
<LinearLayout
android:id="@+id/buttons"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:drawablePadding="4dp"
android:layout_marginTop="12dp" android:gravity="bottom"
android:layout_marginBottom="24dp" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintEnd_toEndOf="parent" app:drawableStartCompat="@drawable/ic_star"
app:layout_constraintStart_toStartOf="parent" app:drawableTint="@color/yellow"
app:layout_constraintTop_toBottomOf="@id/episode_metadata"> tools:text="8.8" />
<RelativeLayout </LinearLayout>
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp">
<ImageButton <LinearLayout
android:id="@+id/play_button" android:id="@+id/buttons"
android:layout_width="72dp" android:layout_width="0dp"
android:layout_height="48dp" android:layout_height="wrap_content"
android:background="@drawable/button_setup_background" android:layout_marginHorizontal="24dp"
android:contentDescription="@string/play_button_description" android:layout_marginTop="12dp"
android:foreground="@drawable/ripple_background" android:layout_marginBottom="24dp"
android:paddingHorizontal="24dp" app:layout_constraintEnd_toEndOf="parent"
android:paddingVertical="12dp" app:layout_constraintStart_toStartOf="parent"
android:src="@drawable/ic_play" /> app:layout_constraintTop_toBottomOf="@id/episode_metadata">
<ProgressBar <RelativeLayout
android:id="@+id/progress_circular" android:layout_width="wrap_content"
android:layout_width="48dp" android:layout_height="wrap_content"
android:layout_height="48dp" android:layout_marginEnd="12dp">
android:layout_centerHorizontal="true"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible" />
</RelativeLayout>
<ImageButton <ImageButton
android:id="@+id/check_button" android:id="@+id/play_button"
android:layout_width="wrap_content" android:layout_width="72dp"
android:layout_height="wrap_content" android:layout_height="48dp"
android:layout_marginEnd="12dp" android:background="@drawable/button_setup_background"
android:background="@drawable/button_accent_background" android:contentDescription="@string/play_button_description"
android:contentDescription="@string/check_button_description" android:foreground="@drawable/ripple_background"
android:padding="12dp" android:paddingHorizontal="24dp"
android:src="@drawable/ic_check" /> android:paddingVertical="12dp"
android:src="@drawable/ic_play" />
<ImageButton <ProgressBar
android:id="@+id/favorite_button" android:id="@+id/progress_circular"
android:layout_width="wrap_content" android:layout_width="48dp"
android:layout_height="wrap_content" android:layout_height="48dp"
android:layout_marginEnd="12dp" android:layout_centerHorizontal="true"
android:background="@drawable/button_accent_background" android:elevation="8dp"
android:contentDescription="@string/favorite_button_description" android:indeterminateTint="@color/white"
android:padding="12dp" android:padding="8dp"
android:src="@drawable/ic_heart" /> android:visibility="invisible" />
</RelativeLayout>
<ImageButton
android:id="@+id/check_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/check_button_description"
android:padding="12dp"
android:src="@drawable/ic_check" />
<ImageButton
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" />
<RelativeLayout
android:id="@+id/download_button_wrapper"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp">
<ImageButton <ImageButton
android:id="@+id/download_button" android:id="@+id/download_button"
android:layout_width="wrap_content" android:layout_width="48dp"
android:layout_height="wrap_content" android:layout_height="48dp"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background" android:background="@drawable/button_accent_background"
android:contentDescription="@string/download_button_description" android:contentDescription="@string/download_button_description"
android:padding="12dp" android:padding="12dp"
android:src="@drawable/ic_download" /> android:src="@drawable/ic_download" />
<ImageButton <ProgressBar
android:id="@+id/delete_button" android:id="@+id/progress_download"
android:layout_width="wrap_content" android:layout_width="48dp"
android:layout_height="wrap_content" android:layout_height="48dp"
android:layout_marginEnd="12dp" android:layout_centerHorizontal="true"
android:background="@drawable/button_accent_background" android:elevation="8dp"
android:contentDescription="@string/delete_button_description" android:indeterminateTint="@color/white"
android:padding="12dp" android:padding="8dp"
android:src="@drawable/ic_trash" /> android:visibility="invisible" />
</LinearLayout> </RelativeLayout>
<LinearLayout <ImageButton
android:id="@+id/player_items_error" android:id="@+id/delete_button"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginEnd="12dp"
android:layout_marginTop="12dp" android:background="@drawable/button_accent_background"
android:layout_marginBottom="12dp" android:contentDescription="@string/delete_button_description"
android:orientation="horizontal" android:padding="12dp"
android:visibility="gone" android:src="@drawable/ic_trash" />
app:layout_constraintEnd_toEndOf="parent" </LinearLayout>
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/buttons"
tools:visibility="visible">
<TextView <LinearLayout
android:id="@+id/player_items_error_text" android:id="@+id/player_items_error"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginHorizontal="24dp"
android:text="@string/error_preparing_player_items" android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:layout_marginBottom="12dp"
android:textColor="?attr/colorError" /> android:orientation="horizontal"
android:visibility="gone"
<TextView app:layout_constraintEnd_toEndOf="parent"
android:id="@+id/player_items_error_details" app:layout_constraintStart_toStartOf="parent"
android:layout_width="wrap_content" app:layout_constraintTop_toBottomOf="@id/buttons"
android:layout_height="wrap_content" tools:visibility="visible">
android:text="@string/view_details_underlined"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?attr/colorError" />
</LinearLayout>
<TextView <TextView
android:layout_width="0dp" android:id="@+id/player_items_error_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginEnd="8dp"
android:layout_marginTop="12dp" android:text="@string/error_preparing_player_items"
android:text="@{viewModel.item.overview}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintEnd_toEndOf="parent" android:textColor="?attr/colorError" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/player_items_error"
tools:text="After one hundred years of peace, humanity is suddenly reminded of the terror of being at the Titans' mercy." />
</androidx.constraintlayout.widget.ConstraintLayout> <TextView
android:id="@+id/player_items_error_details"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/view_details_underlined"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?attr/colorError" />
</LinearLayout>
</layout> <TextView
android:id="@+id/overview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/player_items_error"
tools:text="After one hundred years of peace, humanity is suddenly reminded of the terror of being at the Titans' mercy." />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

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

@ -1,61 +1,48 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<data> <com.google.android.material.progressindicator.LinearProgressIndicator
<variable android:id="@+id/loading_indicator"
name="viewModel" android:layout_width="0dp"
type="dev.jdtech.jellyfin.viewmodels.FavoriteViewModel" /> android:layout_height="wrap_content"
</data> android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout <include
android:layout_width="match_parent" android:id="@+id/error_layout"
android:layout_height="match_parent"> layout="@layout/error_panel" />
<com.google.android.material.progressindicator.CircularProgressIndicator <TextView
android:id="@+id/loading_indicator" android:id="@+id/no_favorites_text"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:indeterminate="true" android:text="@string/no_favorites"
app:layout_constraintBottom_toBottomOf="parent" android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
app:trackCornerRadius="10dp" /> app:layout_constraintTop_toTopOf="parent" />
<include <androidx.recyclerview.widget.RecyclerView
android:id="@+id/error_layout" android:id="@+id/favorites_recycler_view"
layout="@layout/error_panel" /> android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="16dp"
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/favorite_section" />
<TextView </androidx.constraintlayout.widget.ConstraintLayout>
android:id="@+id/no_favorites_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_favorites"
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/favorites_recycler_view"
android:layout_width="0dp"
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:layout_constraintVertical_bias="0.0"
tools:itemCount="4"
tools:listitem="@layout/favorite_section" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -1,63 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android" <androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
> android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<data> <androidx.constraintlayout.widget.ConstraintLayout
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.HomeViewModel"
/>
</data>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresh_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
> tools:context=".fragments.HomeFragment">
<androidx.constraintlayout.widget.ConstraintLayout <com.google.android.material.progressindicator.LinearProgressIndicator
android:layout_width="match_parent" android:id="@+id/loading_indicator"
android:layout_height="match_parent" android:layout_width="0dp"
tools:context=".fragments.HomeFragment" android:layout_height="wrap_content"
> android:indeterminate="true"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.progressindicator.LinearProgressIndicator <include
android:id="@+id/loading_indicator" android:id="@+id/error_layout"
android:layout_width="0dp" layout="@layout/error_panel" />
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:visibility="gone"
/>
<include <androidx.recyclerview.widget.RecyclerView
android:id="@+id/error_layout" android:id="@+id/views_recycler_view"
layout="@layout/error_panel" android:layout_width="0dp"
/> android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="16dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:itemCount="4"
tools:listitem="@layout/view_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
android:id="@+id/views_recycler_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="16dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:views="@{viewModel.views()}"
tools:itemCount="4"
tools:listitem="@layout/view_item"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</layout>

View file

@ -1,52 +1,42 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.LibraryFragment">
<data> <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" />
<variable <include
name="viewModel" android:id="@+id/error_layout"
type="dev.jdtech.jellyfin.viewmodels.LibraryViewModel" /> layout="@layout/error_panel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent" android:id="@+id/items_recycler_view"
android:layout_height="match_parent" android:layout_width="0dp"
tools:context=".fragments.LibraryFragment"> android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:paddingTop="16dp"
android:scrollbars="none"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="@integer/library_columns"
tools:itemCount="6"
tools:listitem="@layout/base_item" />
<com.google.android.material.progressindicator.CircularProgressIndicator </androidx.constraintlayout.widget.ConstraintLayout>
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" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/items_recycler_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:paddingTop="16dp"
android:scrollbars="none"
app:items="@{viewModel.items}"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="@integer/library_columns"
tools:itemCount="6"
tools:listitem="@layout/base_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -1,51 +1,42 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
tools:context=".fragments.MediaFragment">
<data> <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" />
<variable <include
name="viewModel" android:id="@+id/error_layout"
type="dev.jdtech.jellyfin.viewmodels.MediaViewModel" /> layout="@layout/error_panel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent" android:id="@+id/views_recycler_view"
android:layout_height="match_parent" android:layout_width="0dp"
android:animateLayoutChanges="true" android:layout_height="0dp"
tools:context=".fragments.MediaFragment"> android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:paddingTop="16dp"
android:scrollbars="none"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="@integer/collection_columns"
tools:itemCount="4"
tools:listitem="@layout/collection_item" />
<com.google.android.material.progressindicator.CircularProgressIndicator </androidx.constraintlayout.widget.ConstraintLayout>
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" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/views_recycler_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:paddingTop="16dp"
android:scrollbars="none"
app:collections="@{viewModel.collections}"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="@integer/collection_columns"
tools:itemCount="4"
tools:listitem="@layout/collection_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -1,469 +1,439 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<data> <include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
<import type="android.view.View" /> <ScrollView
android:id="@+id/media_info_scrollview"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<variable <LinearLayout
name="viewModel" android:layout_width="match_parent"
type="dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel" /> android:layout_height="wrap_content"
</data> android:orientation="vertical"
tools:context=".fragments.MediaInfoFragment">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="200dp"
android:layout_marginBottom="8dp">
<include <ImageView
android:id="@+id/error_layout" android:id="@+id/item_banner"
layout="@layout/error_panel" /> android:layout_width="match_parent"
android:layout_height="200dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
<ScrollView <FrameLayout
android:id="@+id/media_info_scrollview" android:layout_width="0dp"
android:layout_width="0dp" android:layout_height="0dp"
android:layout_height="0dp" android:background="@drawable/header_gradient"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"> app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintBottom_toTopOf="@id/original_title"
app:layout_constraintStart_toStartOf="parent"
tools:text="Alita: Battle Angel" />
<TextView
android:id="@+id/original_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:layout_marginHorizontal="24dp"
tools:context=".fragments.MediaInfoFragment"> android:layout_marginBottom="16dp">
<TextView
android:id="@+id/year"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="2019" />
<TextView
android:id="@+id/playtime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="122 min" />
<TextView
android:id="@+id/official_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="PG-13" />
<TextView
android:id="@+id/community_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="4dp"
android:gravity="bottom"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:drawableStartCompat="@drawable/ic_star"
app:drawableTint="@color/yellow"
tools:text="7.3" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp">
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp">
<ImageButton
android:id="@+id/play_button"
android:layout_width="72dp"
android:layout_height="48dp"
android:background="@drawable/button_setup_background"
android:contentDescription="@string/play_button_description"
android:foreground="@drawable/ripple_background"
android:paddingHorizontal="24dp"
android:paddingVertical="12dp"
android:src="@drawable/ic_play" />
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerHorizontal="true"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible" />
</RelativeLayout>
<ImageButton
android:id="@+id/trailer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/trailer_button_description"
android:padding="12dp"
android:src="@drawable/ic_film" />
<ImageButton
android:id="@+id/check_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/check_button_description"
android:padding="12dp"
android:src="@drawable/ic_check" />
<ImageButton
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/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
android:id="@+id/player_items_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="-12dp"
android:layout_marginBottom="12dp"
android:orientation="horizontal"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/player_items_error_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/error_preparing_player_items"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?attr/colorError" />
<TextView
android:id="@+id/player_items_error_details"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/view_details_underlined"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?attr/colorError" />
</LinearLayout>
<LinearLayout
android:id="@+id/info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/genres_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="200dp" android:layout_height="wrap_content"
android:layout_marginBottom="8dp"> android:layout_marginBottom="12dp">
<ImageView <TextView
android:id="@+id/item_banner" android:id="@+id/genres_title"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="200dp" android:layout_height="wrap_content"
android:scaleType="centerCrop" android:text="@string/genres"
app:itemBackdropImage="@{viewModel.item}" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
<FrameLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/header_gradient"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<TextView <TextView
android:id="@+id/name" android:id="@+id/genres"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginStart="64dp"
android:text="@{viewModel.item.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintBottom_toTopOf="@id/original_title"
app:layout_constraintStart_toStartOf="parent"
tools:text="Alita: Battle Angel" />
<TextView
android:id="@+id/original_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:text="@{viewModel.item.originalTitle}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Action, Science Fiction, Adventure" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/director_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginBottom="12dp">
android:layout_marginBottom="16dp">
<TextView <TextView
android:id="@+id/year" android:id="@+id/director_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:text="@string/director"
android:text="@{viewModel.dateString}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="2019" /> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView <TextView
android:id="@+id/playtime" android:id="@+id/director"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginStart="64dp"
android:text="@{viewModel.runTime}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="122 min" /> app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Robert Rodriguez" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/writers_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp">
<TextView <TextView
android:id="@+id/official_rating" android:id="@+id/writers_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:text="@string/writers"
android:text="@{viewModel.item.officialRating}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="PG-13" /> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView <TextView
android:id="@+id/community_rating" android:id="@+id/writers"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:drawablePadding="4dp" android:layout_marginStart="64dp"
android:gravity="bottom"
android:text="@{viewModel.item.communityRating.toString()}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:drawableStartCompat="@drawable/ic_star" app:layout_constraintEnd_toEndOf="parent"
app:drawableTint="@color/yellow" app:layout_constraintStart_toStartOf="parent"
tools:text="7.3" /> app:layout_constraintTop_toTopOf="parent"
</LinearLayout> tools:text="James Cameron, Laeta Kalogridis, Yukito Kishiro" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp">
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp">
<ImageButton
android:id="@+id/play_button"
android:layout_width="72dp"
android:layout_height="48dp"
android:background="@drawable/button_setup_background"
android:contentDescription="@string/play_button_description"
android:foreground="@drawable/ripple_background"
android:paddingHorizontal="24dp"
android:paddingVertical="12dp"
android:src="@drawable/ic_play" />
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerHorizontal="true"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible" />
</RelativeLayout>
<ImageButton
android:id="@+id/trailer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/trailer_button_description"
android:padding="12dp"
android:src="@drawable/ic_film" />
<ImageButton
android:id="@+id/check_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/check_button_description"
android:padding="12dp"
android:src="@drawable/ic_check" />
<ImageButton
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/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
android:id="@+id/player_items_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="-12dp"
android:layout_marginBottom="12dp"
android:orientation="horizontal"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/player_items_error_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/error_preparing_player_items"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?attr/colorError" />
<TextView
android:id="@+id/player_items_error_details"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/view_details_underlined"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?attr/colorError" />
</LinearLayout>
<LinearLayout
android:id="@+id/info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/genres_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="@{viewModel.item.genres.size() &lt; 1 ? View.GONE : View.VISIBLE}">
<TextView
android:id="@+id/genres_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/genres"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/genres"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:text="@{viewModel.genresString}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Action, Science Fiction, Adventure" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/director_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="@{viewModel.director == null ? View.GONE : View.VISIBLE}">
<TextView
android:id="@+id/director_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/director"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/director"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:text="@{viewModel.director.name}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Robert Rodriguez" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/writers_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="@{viewModel.writers.size() &lt; 1 ? View.GONE : View.VISIBLE}">
<TextView
android:id="@+id/writers_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/writers"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/writers"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:text="@{viewModel.writersString}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="James Cameron, Laeta Kalogridis, Yukito Kishiro" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:text="@{viewModel.item.overview}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="An angel falls. A warrior rises. When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:orientation="vertical"
android:visibility="@{viewModel.nextUp != null ? View.VISIBLE : View.GONE}">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
android:text="@string/next_up"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/next_up"
android:layout_width="@dimen/nextup_media_width"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:clickable="true"
android:focusable="true"
android:foreground="@drawable/ripple_background"
android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/next_up_image"
android:layout_width="0dp"
android:layout_height="0dp"
android:adjustViewBounds="true"
app:baseItemImage="@{viewModel.nextUp}"
app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Findroid.Image" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@{String.format(@string/episode_name_extended, viewModel.nextUp.parentIndexNumber, viewModel.nextUp.indexNumber, viewModel.nextUp.name)}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/next_up_image"
tools:text="The Girl Flautist" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:orientation="vertical"
android:visibility="@{viewModel.seasons != null ? View.VISIBLE : View.GONE}">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
android:text="@string/seasons"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/seasons_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
app:items="@{viewModel.seasons}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="3"
tools:listitem="@layout/base_item" />
</LinearLayout>
<LinearLayout
android:id="@+id/actors"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
android:text="@string/cast_amp_crew"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/people_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:people="@{viewModel.actors}"
tools:itemCount="3"
tools:listitem="@layout/person_item" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout> <TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="An angel falls. A warrior rises. When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past." />
<LinearLayout
android:id="@+id/next_up_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
android:text="@string/next_up"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/next_up"
android:layout_width="@dimen/nextup_media_width"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:clickable="true"
android:focusable="true"
android:foreground="@drawable/ripple_background"
android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/next_up_image"
android:layout_width="0dp"
android:layout_height="0dp"
android:adjustViewBounds="true"
app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Findroid.Image" />
<TextView
android:id="@+id/next_up_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/next_up_image"
tools:text="The Girl Flautist" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/seasons_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
android:text="@string/seasons"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/seasons_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:itemCount="3"
tools:listitem="@layout/base_item" />
</LinearLayout>
<LinearLayout
android:id="@+id/actors"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
android:text="@string/cast_amp_crew"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/people_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="3"
tools:listitem="@layout/person_item" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,169 +1,153 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<data> <com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<import type="android.view.View" /> <include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
<variable <ScrollView
name="viewModel" android:id="@+id/fragment_content"
type="dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel" /> android:layout_width="0dp"
</data> android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:indeterminate="true" android:orientation="vertical">
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<include <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/error_layout" android:layout_width="match_parent"
layout="@layout/error_panel" /> android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="24dp">
<ScrollView <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/fragment_content" android:id="@+id/person_image"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="210dp"
app:layout_constraintBottom_toBottomOf="parent" android:layout_marginStart="24dp"
app:layout_constraintEnd_toEndOf="parent" android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintDimensionRatio="H,3:2"
app:layout_constraintTop_toTopOf="parent"> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Findroid.Image" />
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/person_image"
app:layout_constraintTop_toTopOf="parent"
tools:text="Actor/Actress name" />
<TextView
android:id="@+id/overview"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toTopOf="@id/read_all"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/person_image"
app:layout_constraintTop_toBottomOf="@+id/name" />
<FrameLayout
android:id="@+id/overview_gradient"
android:layout_width="0dp"
android:layout_height="24dp"
android:background="@drawable/header_gradient"
app:layout_constraintBottom_toBottomOf="@id/overview"
app:layout_constraintEnd_toEndOf="@id/overview"
app:layout_constraintStart_toStartOf="@id/overview" />
<Button
android:id="@+id/read_all"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="24dp"
android:text="@string/view_all"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:orientation="vertical"> android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout <TextView
android:layout_width="match_parent" android:id="@+id/movie_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp" android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"> android:layout_marginBottom="12dp"
android:text="@string/movies_label"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:visibility="gone" />
<com.google.android.material.imageview.ShapeableImageView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/person_image" android:id="@+id/movies_list"
android:layout_width="0dp"
android:layout_height="210dp"
android:layout_marginStart="24dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="H,3:2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Findroid.Image" />
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/person_image"
app:layout_constraintTop_toTopOf="parent"
tools:text="Actor/Actress name" />
<TextView
android:id="@+id/overview"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toTopOf="@id/read_all"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/person_image"
app:layout_constraintTop_toBottomOf="@+id/name" />
<FrameLayout
android:id="@+id/overview_gradient"
android:layout_width="0dp"
android:layout_height="24dp"
android:background="@drawable/header_gradient"
app:layout_constraintBottom_toBottomOf="@id/overview"
app:layout_constraintEnd_toEndOf="@id/overview"
app:layout_constraintStart_toStartOf="@id/overview" />
<Button
android:id="@+id/read_all"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="24dp"
android:text="@string/view_all"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp" android:layout_marginBottom="24dp"
android:orientation="vertical"> android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="4"
tools:listitem="@layout/base_item" />
<TextView <TextView
android:id="@+id/movie_label" android:id="@+id/show_label"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
android:text="@string/movies_label" android:text="@string/shows_label"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1" android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:visibility="@{viewModel.starredIn.movies.empty ? View.GONE : View.VISIBLE}" /> android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/movies_list" android:id="@+id/show_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp" android:clipToPadding="false"
android:clipToPadding="false" android:orientation="horizontal"
android:orientation="horizontal" android:paddingHorizontal="12dp"
android:paddingHorizontal="12dp" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:items="@{viewModel.starredIn.movies}" tools:itemCount="4"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:listitem="@layout/base_item" />
tools:itemCount="4"
tools:listitem="@layout/base_item" />
<TextView
android:id="@+id/show_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
android:text="@string/shows_label"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:visibility="@{viewModel.starredIn.shows.empty ? View.GONE : View.VISIBLE}" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/show_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
app:items="@{viewModel.starredIn.shows}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="4"
tools:listitem="@layout/base_item" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

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

View file

@ -1,48 +1,34 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.SeasonFragment"> tools:context=".fragments.SeasonFragment">
<data> <com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<variable <include
name="viewModel" android:id="@+id/error_layout"
type="dev.jdtech.jellyfin.viewmodels.SeasonViewModel" /> layout="@layout/error_panel" />
</data> <androidx.recyclerview.widget.RecyclerView
android:id="@+id/episodes_recycler_view"
<androidx.constraintlayout.widget.ConstraintLayout android:layout_width="0dp"
android:layout_width="match_parent" android:layout_height="0dp"
android:layout_height="match_parent"> android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
<com.google.android.material.progressindicator.CircularProgressIndicator app:layout_constraintBottom_toBottomOf="parent"
android:id="@+id/loading_indicator" app:layout_constraintEnd_toEndOf="parent"
android:layout_width="wrap_content" app:layout_constraintStart_toStartOf="parent"
android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent"
android:indeterminate="true" tools:itemCount="4"
app:layout_constraintBottom_toBottomOf="parent" tools:listitem="@layout/episode_item" />
app:layout_constraintEnd_toEndOf="parent" </androidx.constraintlayout.widget.ConstraintLayout>
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:trackCornerRadius="10dp" />
<include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/episodes_recycler_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
app:episodes="@{viewModel.episodes}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:itemCount="4"
tools:listitem="@layout/episode_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>