Show people in epsisodes on series overview page (#373)

On the series overview page, show rows below the play/watch/favorite
buttons for cast & crew and guest stars in the episode


![episode_people](https://github.com/user-attachments/assets/2d469369-82d8-4338-9d56-325d4e49addf)
This commit is contained in:
damontecres 2025-12-04 21:12:53 -05:00 committed by GitHub
parent 8ea84b3efe
commit 887b7b597e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 263 additions and 34 deletions

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -28,6 +29,7 @@ fun PersonRow(
people: List<Person>,
onClick: (Person) -> Unit,
modifier: Modifier = Modifier,
@StringRes title: Int = R.string.people,
onLongClick: ((Int, Person) -> Unit)? = null,
) {
val firstFocus = remember { FocusRequester() }
@ -36,7 +38,7 @@ fun PersonRow(
modifier = modifier,
) {
Text(
text = stringResource(R.string.people),
text = stringResource(title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
@ -50,7 +52,7 @@ fun PersonRow(
.fillMaxWidth()
.focusRestorer(firstFocus),
) {
itemsIndexed(people) { index, item ->
itemsIndexed(people, key = { _, item -> item.id }) { index, item ->
PersonCard(
item = item,
onClick = { onClick.invoke(item) },
@ -58,7 +60,8 @@ fun PersonRow(
modifier =
Modifier
.width(120.dp)
.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
.ifElse(index == 0, Modifier.focusRequester(firstFocus))
.animateItem(),
)
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -24,6 +25,7 @@ fun FocusedEpisodeFooter(
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
modifier: Modifier = Modifier,
buttonOnFocusChanged: (FocusState) -> Unit = {},
) {
val dto = ep.data
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
@ -41,7 +43,7 @@ fun FocusedEpisodeFooter(
moreOnClick = moreOnClick,
watchOnClick = watchOnClick,
favoriteOnClick = favoriteOnClick,
buttonOnFocusChanged = {},
buttonOnFocusChanged = buttonOnFocusChanged,
modifier = Modifier,
)
}

View file

@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.data.ChosenStreams
@ -20,6 +22,7 @@ fun FocusedEpisodeHeader(
ep: BaseItem?,
chosenStreams: ChosenStreams?,
overviewOnClick: () -> Unit,
overviewOnFocus: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -44,6 +47,7 @@ fun FocusedEpisodeHeader(
overview = dto?.overview ?: "",
maxLines = 3,
onClick = overviewOnClick,
modifier = Modifier.onFocusChanged(overviewOnFocus),
)
}
}

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.map
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.chooseSource
@ -39,13 +40,16 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUID
@ -85,6 +89,8 @@ fun SeriesOverview(
val context = LocalContext.current
val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() }
val guestStarRowFocusRequester = remember { FocusRequester() }
var initialLoadDone by rememberSaveable { mutableStateOf(false) }
OneTimeLaunchedEffect {
@ -103,6 +109,7 @@ fun SeriesOverview(
val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(listOf())
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf())
val episodeList = (episodes as? EpisodeList.Success)?.episodes
var position by rememberSaveable(
@ -138,6 +145,8 @@ fun SeriesOverview(
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var rowFocused by rememberInt()
LaunchedEffect(episodes) {
episodes?.let { episodes ->
if (episodes is EpisodeList.Success) {
@ -152,12 +161,15 @@ fun SeriesOverview(
}
}
LaunchedEffect(position) {
LaunchedEffect(position, episodes) {
val focusedEpisode =
(episodes as? EpisodeList.Success)
?.episodes
?.getOrNull(position.episodeRowIndex)
?.let {
focusedEpisode?.let {
viewModel.lookUpChosenTracks(it.id, it)
viewModel.lookupPeopleInEpisode(it)
}
}
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
@ -171,7 +183,14 @@ fun SeriesOverview(
LoadingState.Success -> {
series?.let { series ->
LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() }
LaunchedEffect(Unit) {
when (rowFocused) {
EPISODE_ROW -> episodeRowFocusRequester.tryRequestFocus()
CAST_AND_CREW_ROW -> castCrewRowFocusRequester.tryRequestFocus()
GUEST_STAR_ROW -> guestStarRowFocusRequester.tryRequestFocus()
}
}
LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
@ -263,6 +282,7 @@ fun SeriesOverview(
seasons = seasons,
episodes = episodes,
chosenStreams = chosenStreams,
peopleInEpisode = peopleInEpisode,
position = position,
backdropImageUrl =
remember {
@ -273,6 +293,8 @@ fun SeriesOverview(
},
firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester,
guestStarRowFocusRequester = guestStarRowFocusRequester,
onFocus = {
if (it.seasonTabIndex != position.seasonTabIndex) {
seasons.getOrNull(it.seasonTabIndex)?.let { season ->
@ -282,6 +304,7 @@ fun SeriesOverview(
position = it
},
onClick = {
rowFocused = EPISODE_ROW
val resumePosition =
it.data.userData
?.playbackPositionTicks
@ -298,6 +321,7 @@ fun SeriesOverview(
moreDialog = buildMoreForEpisode(ep, true)
},
playOnClick = { resume ->
rowFocused = EPISODE_ROW
episodeList?.getOrNull(position.episodeRowIndex)?.let {
viewModel.release()
viewModel.navigateTo(
@ -337,6 +361,16 @@ fun SeriesOverview(
)
}
},
personOnClick = {
rowFocused =
if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW
viewModel.navigateTo(
Destination.MediaItem(
it.id,
BaseItemKind.PERSON,
),
)
},
modifier = modifier,
)
}
@ -391,3 +425,7 @@ fun SeriesOverview(
)
}
}
private const val EPISODE_ROW = 0
private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1
private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1

View file

@ -1,22 +1,28 @@
package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -24,6 +30,7 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -33,15 +40,18 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
@ -51,6 +61,9 @@ import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.PersonKind
import kotlin.time.Duration
@Composable
@ -60,10 +73,13 @@ fun SeriesOverviewContent(
seasons: List<BaseItem?>,
episodes: EpisodeList,
chosenStreams: ChosenStreams?,
peopleInEpisode: List<Person>,
position: SeriesOverviewPosition,
backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester,
episodeRowFocusRequester: FocusRequester,
castCrewRowFocusRequester: FocusRequester,
guestStarRowFocusRequester: FocusRequester,
onFocus: (SeriesOverviewPosition) -> Unit,
onClick: (BaseItem) -> Unit,
onLongClick: (BaseItem) -> Unit,
@ -72,8 +88,10 @@ fun SeriesOverviewContent(
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
overviewOnClick: () -> Unit,
personOnClick: (Person) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
LaunchedEffect(selectedTabIndex) {
@ -87,24 +105,32 @@ fun SeriesOverviewContent(
var cardRowHasFocus by remember { mutableStateOf(false) }
val dimming by animateFloatAsState(if (pageHasFocus && !cardRowHasFocus) .4f else 1f)
val scrollState = rememberScrollState()
val scrollConnection = rememberDelayedNestedScroll()
Box(
modifier =
modifier
.fillMaxWidth()
// .fillMaxHeight(.33f)
.height(460.dp)
.bringIntoViewRequester(bringIntoViewRequester),
.fillMaxWidth(),
) {
DetailsBackdropImage(backdropImageUrl)
LazyColumn(
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
modifier =
Modifier
.focusRestorer(episodeRowFocusRequester)
.fillMaxSize()
.padding(16.dp)
.focusGroup()
.nestedScroll(scrollConnection)
.verticalScroll(scrollState)
.onFocusChanged { pageHasFocus = it.hasFocus },
) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.focusGroup()
.bringIntoViewRequester(bringIntoViewRequester),
) {
val paddingValues =
if (preferences.appPreferences.interfacePreferences.showClock) {
PaddingValues(start = 16.dp, end = 100.dp)
@ -129,21 +155,22 @@ fun SeriesOverviewContent(
.padding(paddingValues)
.fillMaxWidth(),
)
}
item {
SeriesName(series.name, Modifier)
}
item {
// Episode header
FocusedEpisodeHeader(
preferences = preferences,
ep = focusedEpisode,
chosenStreams = chosenStreams,
overviewOnClick = overviewOnClick,
overviewOnFocus = {
if (it.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
}
},
modifier = Modifier.fillMaxWidth(.6f),
)
}
item {
key(position.seasonTabIndex) {
when (val eps = episodes) {
EpisodeList.Loading -> LoadingPage()
@ -216,7 +243,13 @@ fun SeriesOverviewContent(
Color.Black,
shape = RoundedCornerShape(8.dp),
).alpha(dimming),
),
).onFocusChanged {
if (it.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
}
},
interactionSource = interactionSource,
cardHeight = 120.dp,
)
@ -225,8 +258,7 @@ fun SeriesOverviewContent(
}
}
}
}
item {
focusedEpisode?.let { ep ->
FocusedEpisodeFooter(
preferences = preferences,
@ -239,6 +271,13 @@ fun SeriesOverviewContent(
episodeRowFocusRequester.tryRequestFocus()
},
favoriteOnClick = favoriteOnClick,
buttonOnFocusChanged = {
if (it.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
}
},
modifier =
Modifier
.fillMaxWidth()
@ -246,6 +285,53 @@ fun SeriesOverviewContent(
)
}
}
val castAndCrew =
remember(peopleInEpisode) {
peopleInEpisode.filterNot {
it.type == PersonKind.GUEST_STAR
}
}
val guestStars =
remember(peopleInEpisode) {
peopleInEpisode.filter {
it.type == PersonKind.GUEST_STAR
}
}
AnimatedVisibility(
visible = peopleInEpisode.isNotEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
if (castAndCrew.isNotEmpty()) {
PersonRow(
title = R.string.cast_and_crew,
people = castAndCrew,
onClick = personOnClick,
modifier =
Modifier
.fillMaxWidth()
.focusRequester(castCrewRowFocusRequester),
)
}
if (guestStars.isNotEmpty()) {
PersonRow(
title = R.string.guest_stars,
people = guestStars,
onClick = personOnClick,
modifier =
Modifier
.fillMaxWidth()
.focusRequester(guestStarRowFocusRequester),
)
}
}
}
}
}
}

View file

@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
@ -37,6 +38,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -80,6 +82,8 @@ class SeriesViewModel
val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
fun init(
prefs: UserPreferences,
itemId: UUID,
@ -218,6 +222,7 @@ class SeriesViewModel
ItemFields.CUSTOM_RATING,
ItemFields.TRICKPLAY,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.PEOPLE,
),
)
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
@ -237,11 +242,15 @@ class SeriesViewModel
0
}
Timber.v("Loaded ${pager.size} episodes for season $seasonId, initialIndex=$initialIndex")
return EpisodeList.Success(pager, initialIndex)
return EpisodeList.Success(seasonId, pager, initialIndex)
}
fun loadEpisodes(seasonId: UUID) {
val currentEpisodes = (this@SeriesViewModel.episodes.value as? EpisodeList.Success)
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
this@SeriesViewModel.episodes.value = EpisodeList.Loading
}
viewModelScope.launchIO(ExceptionHandler(true)) {
val episodes =
try {
@ -253,6 +262,12 @@ class SeriesViewModel
withContext(Dispatchers.Main) {
this@SeriesViewModel.episodes.value = episodes
}
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
(episodes as? EpisodeList.Success)
?.let {
it.episodes.getOrNull(it.initialIndex)
}?.let { lookupPeopleInEpisode(it) }
}
}
}
@ -403,6 +418,24 @@ class SeriesViewModel
}
}
}
private var peopleInEpisodeJob: Job? = null
suspend fun lookupPeopleInEpisode(item: BaseItem) {
peopleInEpisodeJob?.cancel()
if (peopleInEpisode.value?.itemId != item.id) {
peopleInEpisode.setValueOnMain(PeopleInItem())
peopleInEpisodeJob =
viewModelScope.launch(ExceptionHandler()) {
delay(250)
val people =
item.data.people
?.letNotEmpty { it.map { Person.fromDto(it, api) } }
.orEmpty()
peopleInEpisode.setValueOnMain(PeopleInItem(item.id, people))
}
}
}
}
sealed interface EpisodeList {
@ -416,7 +449,13 @@ sealed interface EpisodeList {
}
data class Success(
val seasonId: UUID,
val episodes: ApiRequestPager<GetEpisodesRequest>,
val initialIndex: Int,
) : EpisodeList
}
data class PeopleInItem(
val itemId: UUID? = null,
val people: List<Person> = listOf(),
)

View file

@ -0,0 +1,55 @@
package com.github.damontecres.wholphin.ui.util
import androidx.annotation.FloatRange
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
class DelayedNestedScrollConnection(
@param:FloatRange(0.0, 1.0)
private val xDelay: Float = 0f,
@param:FloatRange(0.0, 1.0)
private val yDelay: Float = .6f,
) : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset =
if (source == NestedScrollSource.UserInput) {
Offset(x = available.x * xDelay, y = available.y * yDelay)
} else {
Offset.Zero
}
}
/**
* Add a delay to the scroll speed via user interaction
*
* In some cases, slowing down scrolling makes it feel smoother and less jerky visually
*
* ```kotlin
* val scrollState = rememberScrollState()
* val scrollConnection = rememberDelayedNestedScroll(.5f, .5f)
* Column(
* modifier =
* Modifier
* .nestedScroll(scrollConnection)
* .verticalScroll(scrollState)
* ){ content() }
* ```
*
* @param xDelay how much to delay left-right scrolling
* @param yDelay how much to delay up-down scrolling
*
* @see rememberDelayedNestedScroll
* @see androidx.compose.ui.input.nestedscroll.nestedScroll
*/
@Composable
fun rememberDelayedNestedScroll(
@FloatRange(0.0, 1.0)
xDelay: Float = 0f,
@FloatRange(0.0, 1.0)
yDelay: Float = .33f,
) = remember(xDelay, yDelay) { DelayedNestedScrollConnection(xDelay, yDelay) }

View file

@ -347,6 +347,8 @@
<string name="aspect_ratio">Aspect Ratio</string>
<string name="show_details">Show details</string>
<string name="image_type">Image type</string>
<string name="cast_and_crew"><![CDATA[Cast & Crew]]></string>
<string name="guest_stars">Guest stars</string>
<string-array name="theme_song_volume">