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 com.bumptech.glide.Glide
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.EpisodeItem
import dev.jdtech.jellyfin.adapters.EpisodeListAdapter
import dev.jdtech.jellyfin.adapters.FavoritesListAdapter
import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.adapters.PersonListAdapter
@ -20,7 +16,6 @@ import dev.jdtech.jellyfin.adapters.ViewListAdapter
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.models.DownloadSection
import dev.jdtech.jellyfin.models.FavoriteSection
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
@ -32,12 +27,6 @@ fun bindServers(recyclerView: RecyclerView, data: List<Server>?) {
adapter.submitList(data)
}
@BindingAdapter("views")
fun bindViews(recyclerView: RecyclerView, data: List<HomeItem>?) {
val adapter = recyclerView.adapter as ViewListAdapter
adapter.submitList(data)
}
@BindingAdapter("items")
fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
val adapter = recyclerView.adapter as ViewItemListAdapter
@ -68,12 +57,6 @@ fun bindItemBackdropById(imageView: ImageView, itemId: UUID) {
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")
fun bindPeople(recyclerView: RecyclerView, data: List<BaseItemPerson>?) {
val adapter = recyclerView.adapter as PersonListAdapter
@ -87,12 +70,6 @@ fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) {
.posterDescription(person.name)
}
@BindingAdapter("episodes")
fun bindEpisodes(recyclerView: RecyclerView, data: List<EpisodeItem>?) {
val adapter = recyclerView.adapter as EpisodeListAdapter
adapter.submitList(data)
}
@BindingAdapter("homeEpisodes")
fun bindHomeEpisodes(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
val adapter = recyclerView.adapter as HomeEpisodeListAdapter
@ -136,18 +113,6 @@ fun bindSeasonPoster(imageView: ImageView, seasonId: UUID) {
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 {
val api = JellyfinApi.getInstance(context.applicationContext)

View file

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

View file

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

View file

@ -1,6 +1,5 @@
package dev.jdtech.jellyfin.fragments
import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
@ -9,18 +8,22 @@ import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
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.navArgs
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.bindBaseItemImage
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.requestDownload
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.LocationType
import timber.log.Timber
import java.util.*
@ -39,13 +42,10 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
): View {
binding = EpisodeBottomSheetBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE
viewModel.item.value?.let {
binding.progressCircular.isVisible = true
viewModel.item?.let {
if (!args.isOffline) {
playerViewModel.loadPlayerItems(it)
} 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 ->
when (playerItems) {
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
@ -61,79 +74,46 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}
}
viewModel.item.observe(viewLifecycleOwner, { episode ->
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){
if(!args.isOffline) {
val episodeId: UUID = args.episodeId
binding.checkButton.setOnClickListener {
when (viewModel.played.value) {
true -> viewModel.markAsUnplayed(episodeId)
false -> viewModel.markAsPlayed(episodeId)
when (viewModel.played) {
true -> {
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 {
when (viewModel.favorite.value) {
true -> viewModel.unmarkAsFavorite(episodeId)
false -> viewModel.markAsFavorite(episodeId)
when (viewModel.favorite) {
true -> {
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.isEnabled = false
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)
}else {
} else {
val playerItem = args.playerItem!!
viewModel.loadEpisode(playerItem)
@ -143,14 +123,67 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
findNavController().navigate(R.id.downloadFragment)
}
binding.checkButton.visibility = View.GONE
binding.favoriteButton.visibility = View.GONE
binding.downloadButton.visibility = View.GONE
binding.checkButton.isVisible = false
binding.favoriteButton.isVisible = false
binding.downloadButtonWrapper.isVisible = false
}
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) {
navigateToPlayerActivity(items.items.toTypedArray())
binding.playButton.setImageDrawable(

View file

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

View file

@ -12,7 +12,9 @@ import android.widget.Toast.LENGTH_LONG
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
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.contentType
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import dev.jdtech.jellyfin.viewmodels.HomeViewModel.Loading
import dev.jdtech.jellyfin.viewmodels.HomeViewModel.LoadingError
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
class HomeFragment : Fragment() {
@ -38,6 +40,8 @@ class HomeFragment : Fragment() {
private lateinit var binding: FragmentHomeBinding
private val viewModel: HomeViewModel by viewModels()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@ -66,9 +70,6 @@ class HomeFragment : Fragment() {
): View {
binding = FragmentHomeBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
setupView()
bindState()
@ -84,6 +85,7 @@ class HomeFragment : Fragment() {
private fun setupView() {
binding.refreshLayout.setOnRefreshListener {
viewModel.refreshData()
// binding.refreshLayout.isRefreshing = false
}
binding.viewsRecyclerView.adapter = ViewListAdapter(
@ -99,48 +101,54 @@ class HomeFragment : Fragment() {
.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 {
viewModel.refreshData()
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
errorDialog.show(parentFragmentManager, "errordialog")
}
}
private fun bindLoading(state: Loading) {
binding.errorLayout.errorPanel.isVisible = false
binding.viewsRecyclerView.isVisible = true
private fun bindState() {
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 -> bindUiStateLoading()
is HomeViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
}
}
}
binding.loadingIndicator.visibility = when {
state.inProgress && binding.refreshLayout.isRefreshing -> View.GONE
state.inProgress -> View.VISIBLE
else -> {
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
View.GONE
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) {

View file

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

View file

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

View file

@ -8,23 +8,28 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
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.navArgs
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.PersonListAdapter
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.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.utils.requestDownload
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
@ -36,35 +41,39 @@ class MediaInfoFragment : Fragment() {
private lateinit var binding: FragmentMediaInfoBinding
private val viewModel: MediaInfoViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels()
private val args: MediaInfoFragmentArgs by navArgs()
lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentMediaInfoBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.mediaInfoScrollview.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.mediaInfoScrollview.visibility = View.VISIBLE
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading()
is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
if (!args.isOffline) {
viewModel.loadData(args.itemId, args.itemType)
} else {
viewModel.loadData(args.playerItem!!)
}
}
}
})
if(args.itemType != "Movie") {
binding.downloadButton.visibility = View.GONE
@ -74,36 +83,6 @@ class MediaInfoFragment : Fragment() {
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 ->
when (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 {
if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(viewModel.item.value?.remoteTrailers?.get(0)?.url)
Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url)
)
startActivity(intent)
}
binding.nextUp.setOnClickListener {
navigateToEpisodeBottomSheetFragment(viewModel.nextUp.value!!)
navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!)
}
binding.seasonsRecyclerView.adapter =
@ -166,9 +118,8 @@ class MediaInfoFragment : Fragment() {
binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE
viewModel.item.value?.let { item ->
binding.progressCircular.isVisible = true
viewModel.item?.let { item ->
if (!args.isOffline) {
playerViewModel.loadPlayerItems(item) {
VideoVersionDialogFragment(item, playerViewModel).show(
@ -188,16 +139,28 @@ class MediaInfoFragment : Fragment() {
}
binding.checkButton.setOnClickListener {
when (viewModel.played.value) {
true -> viewModel.markAsUnplayed(args.itemId)
false -> viewModel.markAsPlayed(args.itemId)
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.value) {
true -> viewModel.unmarkAsFavorite(args.itemId)
false -> viewModel.markAsFavorite(args.itemId)
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)
}
}
}
@ -205,23 +168,90 @@ class MediaInfoFragment : Fragment() {
viewModel.loadDownloadRequestItem(args.itemId)
}
binding.deleteButton.visibility = View.GONE
viewModel.loadData(args.itemId, args.itemType)
binding.deleteButton.isVisible = false
} else {
binding.favoriteButton.visibility = View.GONE
binding.checkButton.visibility = View.GONE
binding.downloadButton.visibility = View.GONE
binding.favoriteButton.isVisible = false
binding.checkButton.isVisible = false
binding.downloadButton.isVisible = false
binding.deleteButton.setOnClickListener {
viewModel.deleteItem()
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) {
navigateToPlayerActivity(items.items.toTypedArray())
binding.playButton.setImageDrawable(

View file

@ -9,6 +9,9 @@ import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
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.navArgs
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.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
internal class PersonDetailFragment : Fragment() {
@ -29,15 +34,14 @@ internal class PersonDetailFragment : Fragment() {
private val args: PersonDetailFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentPersonDetailBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
return binding.root
}
@ -47,42 +51,65 @@ internal class PersonDetailFragment : Fragment() {
binding.moviesList.adapter = adapter()
binding.showList.adapter = adapter()
viewModel.data.observe(viewLifecycleOwner) { data ->
binding.name.text = data.name
binding.overview.text = data.overview
setupOverviewExpansion()
bindItemImage(binding.personImage, data.dto)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is PersonDetailViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is PersonDetailViewModel.UiState.Loading -> bindUiStateLoading()
is PersonDetailViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
viewModel.loadData(args.personId)
}
viewModel.finishedLoading.observe(viewLifecycleOwner, {
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
})
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 {
viewModel.loadData(args.personId)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
parentFragmentManager,
"errordialog"
)
errorDialog.show(parentFragmentManager, "errordialog")
}
}
viewModel.loadData(args.personId)
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)
}
}
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(
@ -103,7 +130,6 @@ internal class PersonDetailFragment : Fragment() {
binding.overviewGradient.isVisible = false
}
}
}
}

View file

@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
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.navArgs
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.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
class SearchResultFragment : Fragment() {
private lateinit var binding: FragmentSearchResultBinding
private val viewModel: SearchResultViewModel by viewModels()
private val args: SearchResultFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentSearchResultBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.searchResultsRecyclerView.adapter = FavoritesListAdapter(
ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
@ -42,42 +47,58 @@ class SearchResultFragment : Fragment() {
navigateToEpisodeBottomSheetFragment(item)
})
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished ->
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE
})
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.searchResultsRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.searchResultsRecyclerView.visibility = View.VISIBLE
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is SearchResultViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is SearchResultViewModel.UiState.Loading -> bindUiStateLoading()
is SearchResultViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
viewModel.loadData(args.query)
}
}
})
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.query)
}
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
}
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) {
findNavController().navigate(
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(

View file

@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
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.navArgs
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.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
class SeasonFragment : Fragment() {
private lateinit var binding: FragmentSeasonBinding
private val viewModel: SeasonViewModel by viewModels()
private val args: SeasonFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentSeasonBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.episodesRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.episodesRecyclerView.visibility = View.VISIBLE
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is SeasonViewModel.UiState.Loading -> bindUiStateLoading()
is SeasonViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
viewModel.loadEpisodes(args.seriesId, args.seasonId)
}
}
})
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadEpisodes(args.seriesId, args.seasonId)
}
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 =
EpisodeListAdapter(EpisodeListAdapter.OnClickListener { episode ->
navigateToEpisodeBottomSheetFragment(episode)
}, 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) {

View file

@ -6,12 +6,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.FragmentServerSelectBinding
import dev.jdtech.jellyfin.dialogs.DeleteServerDialogFragment
import dev.jdtech.jellyfin.adapters.ServerGridAdapter
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
import kotlinx.coroutines.launch
@AndroidEntryPoint
class ServerSelectFragment : Fragment() {
@ -44,11 +48,15 @@ class ServerSelectFragment : Fragment() {
navigateToAddServerFragment()
}
viewModel.navigateToMain.observe(viewLifecycleOwner, {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
if (it) {
navigateToMainActivity()
}
})
}
}
}
return binding.root
}
@ -61,6 +69,5 @@ class ServerSelectFragment : Fragment() {
private fun navigateToMainActivity() {
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.Music
import dev.jdtech.jellyfin.models.CollectionType.Playlists
import dev.jdtech.jellyfin.models.CollectionType.BoxSets
enum class CollectionType (val type: String) {
HomeVideos("homevideos"),
Music("music"),
Playlists("playlists"),
Books("books"),
LiveTv("livetv")
LiveTv("livetv"),
BoxSets("boxsets")
}
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.ListRow
import androidx.leanback.widget.ListRowPresenter
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
internal class HomeFragment : BrowseSupportFragment() {
@ -48,7 +53,22 @@ internal class HomeFragment : BrowseSupportFragment() {
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()
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.fragment.app.Fragment
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.navArgs
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.bindBaseItemImage
import dev.jdtech.jellyfin.databinding.MediaDetailFragmentBinding
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
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.PlayerViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItemError
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItems
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
@ -35,7 +37,6 @@ internal class MediaDetailFragment : Fragment() {
private lateinit var binding: MediaDetailFragmentBinding
private val viewModel: MediaInfoViewModel by viewModels()
private val detailViewModel: MediaDetailViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels()
private val args: MediaDetailFragmentArgs by navArgs()
@ -52,28 +53,29 @@ internal class MediaDetailFragment : Fragment() {
savedInstanceState: Bundle?
): View {
binding = MediaDetailFragmentBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
binding.item = detailViewModel.transformData(viewModel.item, resources) {
bindActions(it)
bindState(it)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
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(
fixedWidth = true,
onClickListener = ViewItemListAdapter.OnClickListener {})
viewModel.seasons.observe(viewLifecycleOwner) {
seasonsAdapter.submitList(it)
binding.seasonTitle.isVisible = true
}
binding.seasonsRow.gridView.adapter = seasonsAdapter
binding.seasonsRow.gridView.verticalSpacing = 25
@ -81,33 +83,110 @@ internal class MediaDetailFragment : Fragment() {
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.verticalSpacing = 25
}
private fun bindState(state: MediaDetailViewModel.State) {
playerViewModel.onPlaybackRequested(lifecycleScope) { state ->
when (state) {
is PlayerItemError -> bindPlayerItemsError(state)
is PlayerItems -> bindPlayerItems(state)
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) {
is PlayerItemError -> bindPlayerItemsError(playerItems)
is PlayerItems -> bindPlayerItems(playerItems)
}
}
when (state.media) {
is Movie -> binding.title.text = state.media.title
is TvShow -> with(binding.subtitle) {
binding.title.text = state.media.episode
text = state.media.show
isVisible = true
binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.isVisible = true
viewModel.item?.let { item ->
playerViewModel.loadPlayerItems(item) {
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) {
navigateToPlayerActivity(items.items.toTypedArray())
binding.playButton.setImageDrawable(
@ -132,59 +211,6 @@ internal class MediaDetailFragment : Fragment() {
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(
playerItems: Array<PlayerItem>,
) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,8 +2,6 @@ package dev.jdtech.jellyfin.viewmodels
import android.app.Application
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.toView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject internal constructor(
application: Application,
private val application: Application,
private val repository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val views = MutableLiveData<List<HomeItem>>()
private val state = MutableSharedFlow<State>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
sealed class UiState {
data class Normal(val homeItems: List<HomeItem>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
init {
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)
private fun loadData(updateCapabilities: Boolean) {
state.tryEmit(Loading(inProgress = true))
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
if (updateCapabilities) repository.postCapabilities()
val updated = loadDynamicItems() + loadViews()
views.postValue(updated)
withContext(Dispatchers.Default) {
syncPlaybackProgress(repository)
}
state.tryEmit(Loading(inProgress = false))
uiState.emit(UiState.Normal(updated))
} catch (e: Exception) {
Timber.e(e)
state.tryEmit(LoadingError(e.toString()))
uiState.emit(UiState.Error(e.message))
}
}
}
@ -83,11 +68,11 @@ class HomeViewModel @Inject internal constructor(
val items = mutableListOf<HomeSection>()
if (resumeItems.isNotEmpty()) {
items.add(HomeSection(continueWatchingString, resumeItems))
items.add(HomeSection(application.resources.getString(R.string.continue_watching), resumeItems))
}
if (nextUpItems.isNotEmpty()) {
items.add(HomeSection(nextUpString, nextUpItems))
items.add(HomeSection(application.resources.getString(R.string.next_up), nextUpItems))
}
items.map { Section(it) }
@ -102,11 +87,6 @@ class HomeViewModel @Inject internal constructor(
.map { (view, latest) -> view.toView().apply { items = latest } }
.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 dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.SortBy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.SortOrder
@ -14,16 +16,20 @@ import javax.inject.Inject
@HiltViewModel
class LibraryViewModel
@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>>()
val items: LiveData<List<BaseItemDto>> = _items
sealed class UiState {
data class Normal(val items: List<BaseItemDto>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
fun loadItems(
parentId: UUID,
@ -31,8 +37,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
sortBy: SortBy = SortBy.defaultValue,
sortOrder: SortOrder = SortOrder.ASCENDING
) {
_error.value = null
_finishedLoading.value = false
Timber.d("$libraryType")
val itemType = when (libraryType) {
"movies" -> "Movie"
@ -40,19 +44,19 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
else -> null
}
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
_items.value = jellyfinRepository.getItems(
val items = jellyfinRepository.getItems(
parentId,
includeTypes = if (itemType != null) listOf(itemType) else null,
recursive = true,
sortBy = sortBy,
sortOrder = sortOrder
)
uiState.emit(UiState.Normal(items))
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
uiState.emit(UiState.Error(e.message))
}
_finishedLoading.value = true
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,46 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingDefaultResource"
>
<data>
<import type="android.view.View" />
<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_height="match_parent"
>
tools:ignore="MissingDefaultResource">
<include
android:id="@+id/error_layout"
layout="@layout/error_panel"
tools:visibility="gone"
/>
tools:visibility="gone" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/back_button"
@ -48,12 +25,11 @@
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"
android:focusable="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/title"
@ -65,20 +41,18 @@
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintStart_toEndOf="@id/back_button"
app:layout_constraintTop_toTopOf="parent"
tools:text="Alita: Battle Angel"
/>
tools:text="Alita: Battle Angel" />
<TextClock
android:id="@+id/clock"
android:layout_width="wrap_content"
android:layout_height="24dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="24dp"
android:gravity="center_vertical"
android:textSize="18sp"
android:layout_marginEnd="24dp"
tools:text="12:00"
/>
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="12:00" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/poster"
@ -86,10 +60,8 @@
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"
/>
app:layout_constraintTop_toBottomOf="@id/title" />
<TextView
android:id="@+id/subtitle"
@ -98,34 +70,30 @@
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"
android:visibility="gone"
tools:text="Subtitle"
/>
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"
/>
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"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="2019" />
<TextView
@ -133,11 +101,10 @@
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"
app:layout_constraintStart_toEndOf="@id/year"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="122 min" />
<TextView
@ -145,11 +112,10 @@
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"
app:layout_constraintStart_toEndOf="@id/playtime"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="PG-13" />
<TextView
@ -157,13 +123,12 @@
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"
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
@ -171,28 +136,26 @@
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: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."
/>
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" />
android:visibility="invisible"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/play_button"
@ -200,13 +163,12 @@
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"
android:focusable="true"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/description"
/>
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/trailer_button"
@ -214,12 +176,11 @@
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"
android:focusable="true"
app:layout_constraintStart_toEndOf="@id/play_button"
app:layout_constraintTop_toBottomOf="@id/description"
/>
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/check_button"
@ -227,71 +188,63 @@
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:contentDescription="@string/check_button_description"
android:padding="12dp"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_check"
app:layout_constraintStart_toEndOf="@id/trailer_button"
app:layout_constraintTop_toBottomOf="@id/description"
/>
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"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_heart"
app:layout_constraintStart_toEndOf="@id/check_button"
app:layout_constraintTop_toBottomOf="@id/description"
/>
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: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_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/season_title"
app:layout_constraintStart_toStartOf="parent"
/>
app:layout_constraintTop_toBottomOf="@id/season_title" />
<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:layout_marginTop="16dp"
android:text="@string/cast_amp_crew"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
/>
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/seasons_row" />
<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"
/>
app:layout_constraintTop_toBottomOf="@id/cast_title" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</FrameLayout>
</layout>
</FrameLayout>

View file

@ -1,24 +1,20 @@
<?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:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<import type="org.jellyfin.sdk.model.api.LocationType" />
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="24dp">
<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" />
<ImageView
android:id="@+id/holder"
android:layout_width="wrap_content"
@ -36,7 +32,6 @@
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" />
@ -49,9 +44,10 @@
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">
app:layout_constraintTop_toTopOf="@id/episode_image"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:layout_width="match_parent"
@ -81,7 +77,6 @@
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="24dp"
android:text="@{String.format(@string/episode_name_extended, viewModel.item.parentIndexNumber, viewModel.item.indexNumber, viewModel.item.name)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/episode_image"
@ -104,7 +99,6 @@
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" />
@ -113,7 +107,6 @@
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" />
@ -123,7 +116,6 @@
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"
@ -189,16 +181,32 @@
android:padding="12dp"
android:src="@drawable/ic_heart" />
<ImageButton
android:id="@+id/download_button"
<RelativeLayout
android:id="@+id/download_button_wrapper"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_marginEnd="12dp">
<ImageButton
android:id="@+id/download_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/download_button_description"
android:padding="12dp"
android:src="@drawable/ic_download" />
<ProgressBar
android:id="@+id/progress_download"
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/delete_button"
android:layout_width="wrap_content"
@ -243,17 +251,15 @@
</LinearLayout>
<TextView
android:id="@+id/overview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="12dp"
android:text="@{viewModel.item.overview}"
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>
</layout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,15 +1,7 @@
<?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:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.DownloadViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -33,11 +25,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_downloads"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:visibility="gone"/>
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/downloads_recycler_view"
@ -45,7 +37,6 @@
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"
@ -56,6 +47,4 @@
tools:itemCount="4"
tools:listitem="@layout/download_section" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,28 +1,18 @@
<?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:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.FavoriteViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.progressindicator.CircularProgressIndicator
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="wrap_content"
android:layout_width="0dp"
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" />
app:layout_constraintTop_toTopOf="parent" />
<include
android:id="@+id/error_layout"
@ -33,11 +23,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_favorites"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:visibility="gone"/>
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/favorites_recycler_view"
@ -45,7 +35,6 @@
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"
@ -56,6 +45,4 @@
tools:itemCount="4"
tools:listitem="@layout/favorite_section" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,45 +1,30 @@
<?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:tools="http://schemas.android.com/tools"
>
<data>
<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_height="match_parent"
>
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.HomeFragment"
>
tools:context=".fragments.HomeFragment">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="0dp"
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"
android:visibility="gone"
/>
app:layout_constraintTop_toTopOf="parent" />
<include
android:id="@+id/error_layout"
layout="@layout/error_panel"
/>
layout="@layout/error_panel" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/views_recycler_view"
@ -52,12 +37,8 @@
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"
/>
tools:listitem="@layout/view_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</layout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View file

@ -1,16 +1,7 @@
<?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:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.LibraryViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.LibraryFragment">
@ -26,7 +17,9 @@
app:layout_constraintTop_toTopOf="parent"
app:trackCornerRadius="10dp" />
<include android:id="@+id/error_layout" layout="@layout/error_panel" />
<include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/items_recycler_view"
@ -36,7 +29,6 @@
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"
@ -46,7 +38,5 @@
tools:itemCount="6"
tools:listitem="@layout/base_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,16 +1,7 @@
<?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:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.MediaViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
@ -27,7 +18,9 @@
app:layout_constraintTop_toTopOf="parent"
app:trackCornerRadius="10dp" />
<include android:id="@+id/error_layout" layout="@layout/error_panel" />
<include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/views_recycler_view"
@ -37,7 +30,6 @@
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"
@ -47,5 +39,4 @@
tools:itemCount="4"
tools:listitem="@layout/collection_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,18 +1,7 @@
<?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:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -45,7 +34,6 @@
android:layout_width="match_parent"
android:layout_height="200dp"
android:scaleType="centerCrop"
app:itemBackdropImage="@{viewModel.item}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@ -66,7 +54,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:text="@{viewModel.item.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintBottom_toTopOf="@id/original_title"
app:layout_constraintStart_toStartOf="parent"
@ -77,7 +64,6 @@
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"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
@ -95,7 +81,6 @@
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="2019" />
@ -104,7 +89,6 @@
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="122 min" />
@ -113,7 +97,6 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@{viewModel.item.officialRating}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="PG-13" />
@ -123,7 +106,6 @@
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"
@ -256,8 +238,7 @@
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}">
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/genres_title"
@ -273,7 +254,6 @@
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"
@ -285,8 +265,7 @@
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}">
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/director_title"
@ -302,7 +281,6 @@
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"
@ -314,8 +292,7 @@
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}">
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/writers_title"
@ -331,7 +308,6 @@
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"
@ -347,16 +323,15 @@
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:id="@+id/next_up_layout"
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}">
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
@ -382,7 +357,6 @@
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"
@ -390,10 +364,10 @@
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: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"
@ -404,11 +378,11 @@
</LinearLayout>
<LinearLayout
android:id="@+id/seasons_layout"
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}">
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
@ -426,7 +400,6 @@
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" />
@ -457,13 +430,10 @@
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>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,18 +1,7 @@
<?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:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -120,7 +109,7 @@
android:layout_marginBottom="12dp"
android:text="@string/movies_label"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:visibility="@{viewModel.starredIn.movies.empty ? View.GONE : View.VISIBLE}" />
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/movies_list"
@ -130,7 +119,6 @@
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
app:items="@{viewModel.starredIn.movies}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="4"
tools:listitem="@layout/base_item" />
@ -143,7 +131,7 @@
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}" />
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/show_list"
@ -152,7 +140,6 @@
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" />
@ -163,7 +150,4 @@
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,31 +1,22 @@
<?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:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.SearchResultViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.progressindicator.CircularProgressIndicator
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="wrap_content"
android:layout_width="0dp"
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" />
app:layout_constraintTop_toTopOf="parent" />
<include android:id="@+id/error_layout" layout="@layout/error_panel" />
<include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
<TextView
android:id="@+id/no_search_results_text"
@ -44,7 +35,6 @@
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"
@ -53,6 +43,4 @@
tools:itemCount="4"
tools:listitem="@layout/favorite_section" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,31 +1,19 @@
<?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:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.SeasonFragment">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.SeasonViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.progressindicator.CircularProgressIndicator
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="wrap_content"
android:layout_width="0dp"
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" />
app:layout_constraintTop_toTopOf="parent" />
<include
android:id="@+id/error_layout"
@ -36,7 +24,6 @@
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"
@ -44,5 +31,4 @@
app:layout_constraintTop_toTopOf="parent"
tools:itemCount="4"
tools:listitem="@layout/episode_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</androidx.constraintlayout.widget.ConstraintLayout>