Add person detail screen (#47)

* Add person detail screen

Displays actor/actresses portrait and text info + list of movies/show this person starred in. Text info is max 5 lines with View More button if ellipsized. View More toggle is reset upon orientation change since in landscape mode ellipsize might not be necessary.

* Remove useless StarredInAdapter.kt

* Fix image view shape

* Improve UI

Not exactly how I would like it but will do for now

* Add error handling

Adds a lot of LiveData which may not be ideal, but is better than crashing due to connection errors.

Co-authored-by: jarnedemeulemeester <jarnedemeulemeester@gmail.com>
This commit is contained in:
lsrom 2021-10-24 17:45:59 +02:00 committed by GitHub
parent e9e849d9e4
commit 62d09b3566
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 477 additions and 20 deletions

View file

@ -5,14 +5,23 @@ import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import dev.jdtech.jellyfin.adapters.* 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.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.models.FavoriteSection import dev.jdtech.jellyfin.models.FavoriteSection
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import java.util.* import java.util.UUID
@BindingAdapter("servers") @BindingAdapter("servers")
fun bindServers(recyclerView: RecyclerView, data: List<Server>?) { fun bindServers(recyclerView: RecyclerView, data: List<Server>?) {

View file

@ -8,7 +8,8 @@ import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.databinding.PersonItemBinding import dev.jdtech.jellyfin.databinding.PersonItemBinding
import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.BaseItemPerson
class PersonListAdapter :ListAdapter<BaseItemPerson, PersonListAdapter.PersonViewHolder>(DiffCallback) { class PersonListAdapter(private val clickListener: (item: BaseItemPerson) -> Unit) :ListAdapter<BaseItemPerson, PersonListAdapter.PersonViewHolder>(DiffCallback) {
class PersonViewHolder(private var binding: PersonItemBinding) : class PersonViewHolder(private var binding: PersonItemBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(person: BaseItemPerson) { fun bind(person: BaseItemPerson) {
@ -40,5 +41,6 @@ class PersonListAdapter :ListAdapter<BaseItemPerson, PersonListAdapter.PersonVie
override fun onBindViewHolder(holder: PersonViewHolder, position: Int) { override fun onBindViewHolder(holder: PersonViewHolder, position: Int) {
val item = getItem(position) val item = getItem(position)
holder.bind(item) holder.bind(item)
holder.itemView.setOnClickListener { clickListener(item) }
} }
} }

View file

@ -20,6 +20,7 @@ class ViewListAdapter(
private val onItemClickListener: ViewItemListAdapter.OnClickListener, private val onItemClickListener: ViewItemListAdapter.OnClickListener,
private val onNextUpClickListener: HomeEpisodeListAdapter.OnClickListener private val onNextUpClickListener: HomeEpisodeListAdapter.OnClickListener
) : ListAdapter<HomeItem, RecyclerView.ViewHolder>(DiffCallback) { ) : ListAdapter<HomeItem, RecyclerView.ViewHolder>(DiffCallback) {
class ViewViewHolder(private var binding: ViewItemBinding) : class ViewViewHolder(private var binding: ViewItemBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind( fun bind(

View file

@ -7,7 +7,6 @@ import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import java.util.* import java.util.*
/** /**
* Jellyfin API class using org.jellyfin.sdk:jellyfin-platform-android * Jellyfin API class using org.jellyfin.sdk:jellyfin-platform-android
* *

View file

@ -6,6 +6,7 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels 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.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID
@AndroidEntryPoint @AndroidEntryPoint
class MediaInfoFragment : Fragment() { class MediaInfoFragment : Fragment() {
@ -163,7 +166,14 @@ class MediaInfoFragment : Fragment() {
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { season -> ViewItemListAdapter(ViewItemListAdapter.OnClickListener { season ->
navigateToSeasonFragment(season) navigateToSeasonFragment(season)
}, fixedWidth = true) }, 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.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent) binding.playButton.setImageResource(android.R.color.transparent)
@ -229,4 +239,10 @@ class MediaInfoFragment : Fragment() {
) )
) )
} }
private fun navigateToPersonDetail(personId: UUID) {
findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToPersonDetailFragment(personId)
)
}
} }

View file

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

View file

@ -0,0 +1,7 @@
package dev.jdtech.jellyfin.models
enum class ContentType(val type: String) {
MOVIE("Movie"),
TVSHOW("Series"),
UNKNOWN("")
}

View file

@ -1,5 +1,7 @@
package dev.jdtech.jellyfin.repository package dev.jdtech.jellyfin.repository
import dev.jdtech.jellyfin.models.ContentType
import dev.jdtech.jellyfin.utils.SortBy import dev.jdtech.jellyfin.utils.SortBy
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
@ -20,6 +22,12 @@ interface JellyfinRepository {
sortOrder: SortOrder = SortOrder.ASCENDING sortOrder: SortOrder = SortOrder.ASCENDING
): List<BaseItemDto> ): List<BaseItemDto>
suspend fun getPersonItems(
personIds: List<UUID>,
includeTypes: List<ContentType>? = null,
recursive: Boolean = true
): List<BaseItemDto>
suspend fun getFavoriteItems(): List<BaseItemDto> suspend fun getFavoriteItems(): List<BaseItemDto>
suspend fun getSearchItems(searchQuery: String): List<BaseItemDto> suspend fun getSearchItems(searchQuery: String): List<BaseItemDto>

View file

@ -1,6 +1,7 @@
package dev.jdtech.jellyfin.repository package dev.jdtech.jellyfin.repository
import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.models.ContentType
import dev.jdtech.jellyfin.utils.SortBy import dev.jdtech.jellyfin.utils.SortBy
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -13,7 +14,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
val views: List<BaseItemDto> val views: List<BaseItemDto>
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
views = views =
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: listOf() jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: emptyList()
} }
return views return views
} }
@ -43,7 +44,24 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
recursive = recursive, recursive = recursive,
sortBy = listOf(sortBy.SortString), sortBy = listOf(sortBy.SortString),
sortOrder = listOf(sortOrder) sortOrder = listOf(sortOrder)
).content.items ?: listOf() ).content.items ?: emptyList()
}
return items
}
override suspend fun getPersonItems(
personIds: List<UUID>,
includeTypes: List<ContentType>?,
recursive: Boolean
): List<BaseItemDto> {
val items: List<BaseItemDto>
withContext(Dispatchers.IO) {
items = jellyfinApi.itemsApi.getItems(
jellyfinApi.userId!!,
personIds = personIds,
includeItemTypes = includeTypes?.map { it.type },
recursive = recursive
).content.items ?: emptyList()
} }
return items return items
} }
@ -56,7 +74,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
filters = listOf(ItemFilter.IS_FAVORITE), filters = listOf(ItemFilter.IS_FAVORITE),
includeItemTypes = listOf("Movie", "Series", "Episode"), includeItemTypes = listOf("Movie", "Series", "Episode"),
recursive = true recursive = true
).content.items ?: listOf() ).content.items ?: emptyList()
} }
return items return items
} }
@ -69,7 +87,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
searchTerm = searchQuery, searchTerm = searchQuery,
includeItemTypes = listOf("Movie", "Series", "Episode"), includeItemTypes = listOf("Movie", "Series", "Episode"),
recursive = true recursive = true
).content.items ?: listOf() ).content.items ?: emptyList()
} }
return items return items
} }
@ -81,7 +99,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
jellyfinApi.itemsApi.getResumeItems( jellyfinApi.itemsApi.getResumeItems(
jellyfinApi.userId!!, jellyfinApi.userId!!,
includeItemTypes = listOf("Movie", "Episode"), includeItemTypes = listOf("Movie", "Episode"),
).content.items ?: listOf() ).content.items ?: emptyList()
} }
return items return items
} }
@ -101,7 +119,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
val seasons: List<BaseItemDto> val seasons: List<BaseItemDto>
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
seasons = jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items seasons = jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items
?: listOf() ?: emptyList()
} }
return seasons return seasons
} }
@ -112,7 +130,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
nextUpItems = jellyfinApi.showsApi.getNextUp( nextUpItems = jellyfinApi.showsApi.getNextUp(
jellyfinApi.userId!!, jellyfinApi.userId!!,
seriesId = seriesId?.toString(), seriesId = seriesId?.toString(),
).content.items ?: listOf() ).content.items ?: emptyList()
} }
return nextUpItems return nextUpItems
} }
@ -131,7 +149,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
seasonId = seasonId, seasonId = seasonId,
fields = fields, fields = fields,
startItemId = startItemId startItemId = startItemId
).content.items ?: listOf() ).content.items ?: emptyList()
} }
return episodes return episodes
} }
@ -145,15 +163,15 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
name = "Direct play all", name = "Direct play all",
maxStaticBitrate = 1_000_000_000, maxStaticBitrate = 1_000_000_000,
maxStreamingBitrate = 1_000_000_000, maxStreamingBitrate = 1_000_000_000,
codecProfiles = listOf(), codecProfiles = emptyList(),
containerProfiles = listOf(), containerProfiles = emptyList(),
directPlayProfiles = listOf( directPlayProfiles = listOf(
DirectPlayProfile( DirectPlayProfile(
type = DlnaProfileType.VIDEO type = DlnaProfileType.VIDEO
), DirectPlayProfile(type = DlnaProfileType.AUDIO) ), DirectPlayProfile(type = DlnaProfileType.AUDIO)
), ),
transcodingProfiles = listOf(), transcodingProfiles = emptyList(),
responseProfiles = listOf(), responseProfiles = emptyList(),
enableAlbumArtInDidl = false, enableAlbumArtInDidl = false,
enableMsMediaReceiverRegistrar = false, enableMsMediaReceiverRegistrar = false,
enableSingleAlbumArtLimit = false, enableSingleAlbumArtLimit = false,
@ -171,7 +189,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
maxStreamingBitrate = 1_000_000_000, maxStreamingBitrate = 1_000_000_000,
) )
) )
mediaSourceInfoList = mediaInfo.mediaSources ?: listOf() mediaSourceInfoList = mediaInfo.mediaSources ?: emptyList()
return mediaSourceInfoList return mediaSourceInfoList
} }
@ -278,7 +296,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
intros = intros =
jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items
?: listOf() ?: emptyList()
} }
return intros return intros
} }

View file

@ -3,6 +3,7 @@ package dev.jdtech.jellyfin.utils
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dev.jdtech.jellyfin.MainNavigationDirections import dev.jdtech.jellyfin.MainNavigationDirections
import dev.jdtech.jellyfin.models.ContentType
import dev.jdtech.jellyfin.models.View import dev.jdtech.jellyfin.models.View
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber 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) { fun Fragment.checkIfLoginRequired(error: String) {
if (error.contains("401")) { if (error.contains("401")) {
Timber.d("Login required!") Timber.d("Login required!")

View file

@ -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<PersonOverview>()
val starredIn = MutableLiveData<StarredIn>()
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _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<BaseItemDto>,
val shows: List<BaseItemDto>
)
}

View file

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

View file

@ -15,7 +15,9 @@
android:layout_width="110dp" android:layout_width="110dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp" android:layout_marginHorizontal="8dp"
android:foreground="?android:attr/selectableItemBackground" android:clickable="true"
android:focusable="true"
android:foreground="@drawable/ripple_background"
android:orientation="vertical"> android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView <com.google.android.material.imageview.ShapeableImageView

View file

@ -116,6 +116,10 @@
<action <action
android:id="@+id/action_mediaInfoFragment_to_playerActivity" android:id="@+id/action_mediaInfoFragment_to_playerActivity"
app:destination="@id/playerActivity" /> app:destination="@id/playerActivity" />
<action
android:id="@+id/action_mediaInfoFragment_to_personDetailFragment"
app:destination="@id/personDetailFragment"
/>
<argument <argument
android:name="itemType" android:name="itemType"
app:argType="string" /> app:argType="string" />
@ -228,6 +232,21 @@
app:popUpToInclusive="true" /> app:popUpToInclusive="true" />
</fragment> </fragment>
<fragment
android:id="@+id/personDetailFragment"
android:name="dev.jdtech.jellyfin.fragments.PersonDetailFragment"
android:label="@string/person_detail_title"
tools:layout="@layout/fragment_person_detail">
<argument
android:name="personId"
app:argType="java.util.UUID" />
<action
android:id="@+id/action_personDetailFragment_to_mediaInfoFragment"
app:destination="@id/mediaInfoFragment" />
</fragment>
<include app:graph="@navigation/aboutlibs_navigation" /> <include app:graph="@navigation/aboutlibs_navigation" />
<action <action
android:id="@+id/action_global_loginFragment" android:id="@+id/action_global_loginFragment"

View file

@ -65,6 +65,11 @@
<string name="mpv_player_summary">Use the experimental MPV Player to play videos. MPV has support for more video, audio and subtitle codecs.</string> <string name="mpv_player_summary">Use the experimental MPV Player to play videos. MPV has support for more video, audio and subtitle codecs.</string>
<string name="force_software_decoding">Force software decoding</string> <string name="force_software_decoding">Force software decoding</string>
<string name="force_software_decoding_summary">Disable hardware decoding and use software decoding. Can be useful if hardware decoding gives weird artifacts.</string> <string name="force_software_decoding_summary">Disable hardware decoding and use software decoding. Can be useful if hardware decoding gives weird artifacts.</string>
<string name="person_detail_title">Person Detail</string>
<string name="error_getting_person_id">Detail unavailable</string>
<string name="movies_label">Movies</string>
<string name="shows_label">TV Shows</string>
<string name="hide">Hide</string>
<string name="sort_by">Sort by</string> <string name="sort_by">Sort by</string>
<string name="sort_order">Sort order</string> <string name="sort_order">Sort order</string>
<string name="close">Close</string> <string name="close">Close</string>