diff --git a/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt b/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt index c6c86ae6..855bd71e 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt @@ -5,14 +5,23 @@ 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.* +import dev.jdtech.jellyfin.adapters.CollectionListAdapter +import dev.jdtech.jellyfin.adapters.EpisodeItem +import dev.jdtech.jellyfin.adapters.EpisodeListAdapter +import dev.jdtech.jellyfin.adapters.FavoritesListAdapter +import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter +import dev.jdtech.jellyfin.adapters.HomeItem +import dev.jdtech.jellyfin.adapters.PersonListAdapter +import dev.jdtech.jellyfin.adapters.ServerGridAdapter +import dev.jdtech.jellyfin.adapters.ViewItemListAdapter +import dev.jdtech.jellyfin.adapters.ViewListAdapter import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.database.Server 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 -import java.util.* +import java.util.UUID @BindingAdapter("servers") fun bindServers(recyclerView: RecyclerView, data: List?) { diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/PersonListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/PersonListAdapter.kt index b1654b53..1c85cfb1 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/adapters/PersonListAdapter.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/PersonListAdapter.kt @@ -8,7 +8,8 @@ import androidx.recyclerview.widget.RecyclerView import dev.jdtech.jellyfin.databinding.PersonItemBinding import org.jellyfin.sdk.model.api.BaseItemPerson -class PersonListAdapter :ListAdapter(DiffCallback) { +class PersonListAdapter(private val clickListener: (item: BaseItemPerson) -> Unit) :ListAdapter(DiffCallback) { + class PersonViewHolder(private var binding: PersonItemBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(person: BaseItemPerson) { @@ -40,5 +41,6 @@ class PersonListAdapter :ListAdapter(DiffCallback) { + class ViewViewHolder(private var binding: ViewItemBinding) : RecyclerView.ViewHolder(binding.root) { fun bind( diff --git a/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt b/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt index 272e780f..5b469423 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt @@ -7,7 +7,6 @@ import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.model.ClientInfo import java.util.* - /** * Jellyfin API class using org.jellyfin.sdk:jellyfin-platform-android * diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt index 8ff29f13..103fd145 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels @@ -22,6 +23,8 @@ import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.serializer.toUUID +import java.util.UUID @AndroidEntryPoint class MediaInfoFragment : Fragment() { @@ -163,7 +166,14 @@ class MediaInfoFragment : Fragment() { ViewItemListAdapter(ViewItemListAdapter.OnClickListener { season -> navigateToSeasonFragment(season) }, fixedWidth = true) - binding.peopleRecyclerView.adapter = PersonListAdapter() + binding.peopleRecyclerView.adapter = PersonListAdapter { person -> + val uuid = person.id?.toUUID() + if (uuid != null) { + navigateToPersonDetail(uuid) + } else { + Toast.makeText(requireContext(), R.string.error_getting_person_id, Toast.LENGTH_SHORT).show() + } + } binding.playButton.setOnClickListener { binding.playButton.setImageResource(android.R.color.transparent) @@ -229,4 +239,10 @@ class MediaInfoFragment : Fragment() { ) ) } + + private fun navigateToPersonDetail(personId: UUID) { + findNavController().navigate( + MediaInfoFragmentDirections.actionMediaInfoFragmentToPersonDetailFragment(personId) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt new file mode 100644 index 00000000..52f27b37 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt @@ -0,0 +1,119 @@ +package dev.jdtech.jellyfin.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +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.ViewItemListAdapter +import dev.jdtech.jellyfin.bindItemImage +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 org.jellyfin.sdk.model.api.BaseItemDto + +@AndroidEntryPoint +internal class PersonDetailFragment : Fragment() { + + private lateinit var binding: FragmentPersonDetailBinding + private val viewModel: PersonDetailViewModel by viewModels() + + private val args: PersonDetailFragmentArgs by navArgs() + + 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 + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + 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) + } + + 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" + ) + } + + viewModel.loadData(args.personId) + } + + private fun adapter() = ViewItemListAdapter( + fixedWidth = true, + onClickListener = ViewItemListAdapter.OnClickListener { navigateToMediaInfoFragment(it) } + ) + + private fun setupOverviewExpansion() = binding.overview.post { + binding.readAll.setOnClickListener { + with(binding.overview) { + if (layoutParams.height == ConstraintLayout.LayoutParams.WRAP_CONTENT) { + updateLayoutParams { height = ConstraintLayout.LayoutParams.MATCH_CONSTRAINT } + binding.readAll.text = getString(R.string.view_all) + binding.overviewGradient.isVisible = true + } else { + updateLayoutParams { height = ConstraintLayout.LayoutParams.WRAP_CONTENT } + binding.readAll.text = getString(R.string.hide) + binding.overviewGradient.isVisible = false + } + } + + } + } + + private fun navigateToMediaInfoFragment(item: BaseItemDto) { + findNavController().navigate( + PersonDetailFragmentDirections.actionPersonDetailFragmentToMediaInfoFragment( + itemId = item.id, + itemName = item.name, + itemType = item.type ?: "Unknown" + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/ContentType.kt b/app/src/main/java/dev/jdtech/jellyfin/models/ContentType.kt new file mode 100644 index 00000000..f06b5a80 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/models/ContentType.kt @@ -0,0 +1,7 @@ +package dev.jdtech.jellyfin.models + +enum class ContentType(val type: String) { + MOVIE("Movie"), + TVSHOW("Series"), + UNKNOWN("") +} \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt index 88a7ec1b..731d19f8 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt @@ -1,5 +1,7 @@ package dev.jdtech.jellyfin.repository + +import dev.jdtech.jellyfin.models.ContentType import dev.jdtech.jellyfin.utils.SortBy import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.ItemFields @@ -20,6 +22,12 @@ interface JellyfinRepository { sortOrder: SortOrder = SortOrder.ASCENDING ): List + suspend fun getPersonItems( + personIds: List, + includeTypes: List? = null, + recursive: Boolean = true + ): List + suspend fun getFavoriteItems(): List suspend fun getSearchItems(searchQuery: String): List diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt index 90bd7e77..f0e4a9b0 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt @@ -1,6 +1,7 @@ package dev.jdtech.jellyfin.repository import dev.jdtech.jellyfin.api.JellyfinApi +import dev.jdtech.jellyfin.models.ContentType import dev.jdtech.jellyfin.utils.SortBy import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -13,7 +14,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep val views: List withContext(Dispatchers.IO) { views = - jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: listOf() + jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: emptyList() } return views } @@ -43,7 +44,24 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep recursive = recursive, sortBy = listOf(sortBy.SortString), sortOrder = listOf(sortOrder) - ).content.items ?: listOf() + ).content.items ?: emptyList() + } + return items + } + + override suspend fun getPersonItems( + personIds: List, + includeTypes: List?, + recursive: Boolean + ): List { + val items: List + withContext(Dispatchers.IO) { + items = jellyfinApi.itemsApi.getItems( + jellyfinApi.userId!!, + personIds = personIds, + includeItemTypes = includeTypes?.map { it.type }, + recursive = recursive + ).content.items ?: emptyList() } return items } @@ -56,7 +74,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep filters = listOf(ItemFilter.IS_FAVORITE), includeItemTypes = listOf("Movie", "Series", "Episode"), recursive = true - ).content.items ?: listOf() + ).content.items ?: emptyList() } return items } @@ -69,7 +87,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep searchTerm = searchQuery, includeItemTypes = listOf("Movie", "Series", "Episode"), recursive = true - ).content.items ?: listOf() + ).content.items ?: emptyList() } return items } @@ -81,7 +99,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep jellyfinApi.itemsApi.getResumeItems( jellyfinApi.userId!!, includeItemTypes = listOf("Movie", "Episode"), - ).content.items ?: listOf() + ).content.items ?: emptyList() } return items } @@ -101,7 +119,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep val seasons: List withContext(Dispatchers.IO) { seasons = jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items - ?: listOf() + ?: emptyList() } return seasons } @@ -112,7 +130,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep nextUpItems = jellyfinApi.showsApi.getNextUp( jellyfinApi.userId!!, seriesId = seriesId?.toString(), - ).content.items ?: listOf() + ).content.items ?: emptyList() } return nextUpItems } @@ -131,7 +149,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep seasonId = seasonId, fields = fields, startItemId = startItemId - ).content.items ?: listOf() + ).content.items ?: emptyList() } return episodes } @@ -145,15 +163,15 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep name = "Direct play all", maxStaticBitrate = 1_000_000_000, maxStreamingBitrate = 1_000_000_000, - codecProfiles = listOf(), - containerProfiles = listOf(), + codecProfiles = emptyList(), + containerProfiles = emptyList(), directPlayProfiles = listOf( DirectPlayProfile( type = DlnaProfileType.VIDEO ), DirectPlayProfile(type = DlnaProfileType.AUDIO) ), - transcodingProfiles = listOf(), - responseProfiles = listOf(), + transcodingProfiles = emptyList(), + responseProfiles = emptyList(), enableAlbumArtInDidl = false, enableMsMediaReceiverRegistrar = false, enableSingleAlbumArtLimit = false, @@ -171,7 +189,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep maxStreamingBitrate = 1_000_000_000, ) ) - mediaSourceInfoList = mediaInfo.mediaSources ?: listOf() + mediaSourceInfoList = mediaInfo.mediaSources ?: emptyList() return mediaSourceInfoList } @@ -278,7 +296,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep withContext(Dispatchers.IO) { intros = jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items - ?: listOf() + ?: emptyList() } return intros } diff --git a/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt b/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt index 55ec8723..6d300c93 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt @@ -3,6 +3,7 @@ package dev.jdtech.jellyfin.utils import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import dev.jdtech.jellyfin.MainNavigationDirections +import dev.jdtech.jellyfin.models.ContentType import dev.jdtech.jellyfin.models.View import org.jellyfin.sdk.model.api.BaseItemDto import timber.log.Timber @@ -15,6 +16,12 @@ fun BaseItemDto.toView(): View { ) } +fun BaseItemDto.contentType() = when (type) { + "Movie" -> ContentType.MOVIE + "Series" -> ContentType.TVSHOW + else -> ContentType.UNKNOWN +} + fun Fragment.checkIfLoginRequired(error: String) { if (error.contains("401")) { Timber.d("Login required!") diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PersonDetailViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PersonDetailViewModel.kt new file mode 100644 index 00000000..070b4683 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PersonDetailViewModel.kt @@ -0,0 +1,76 @@ +package dev.jdtech.jellyfin.viewmodels + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.jdtech.jellyfin.models.ContentType.MOVIE +import dev.jdtech.jellyfin.models.ContentType.TVSHOW +import dev.jdtech.jellyfin.repository.JellyfinRepository +import dev.jdtech.jellyfin.utils.contentType +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 + +@HiltViewModel +internal class PersonDetailViewModel @Inject internal constructor( + private val jellyfinRepository: JellyfinRepository +) : ViewModel() { + + val data = MutableLiveData() + val starredIn = MutableLiveData() + + private val _finishedLoading = MutableLiveData() + val finishedLoading: LiveData = _finishedLoading + + private val _error = MutableLiveData() + val error: LiveData = _error + + fun loadData(personId: UUID) { + _error.value = null + _finishedLoading.value = false + viewModelScope.launch { + try { + val personDetail = jellyfinRepository.getItem(personId) + + data.postValue( + PersonOverview( + name = personDetail.name.orEmpty(), + overview = personDetail.overview.orEmpty(), + dto = personDetail + ) + ) + val items = jellyfinRepository.getPersonItems( + personIds = listOf(personId), + includeTypes = listOf(MOVIE, TVSHOW), + recursive = true + ) + + val movies = items.filter { it.contentType() == MOVIE } + val shows = items.filter { it.contentType() == TVSHOW } + + starredIn.postValue(StarredIn(movies, shows)) + + } catch (e: Exception) { + Timber.e(e) + _error.value = e.toString() + } + _finishedLoading.value = true + } + } + + data class PersonOverview( + val name: String, + val overview: String, + val dto: BaseItemDto + ) + + data class StarredIn( + val movies: List, + val shows: List + ) +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_person_detail.xml b/app/src/main/res/layout/fragment_person_detail.xml new file mode 100644 index 00000000..346d5bb8 --- /dev/null +++ b/app/src/main/res/layout/fragment_person_detail.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +