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 package com.github.damontecres.wholphin.ui.cards
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -28,6 +29,7 @@ fun PersonRow(
people: List<Person>, people: List<Person>,
onClick: (Person) -> Unit, onClick: (Person) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@StringRes title: Int = R.string.people,
onLongClick: ((Int, Person) -> Unit)? = null, onLongClick: ((Int, Person) -> Unit)? = null,
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
@ -36,7 +38,7 @@ fun PersonRow(
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = stringResource(R.string.people), text = stringResource(title),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
@ -50,7 +52,7 @@ fun PersonRow(
.fillMaxWidth() .fillMaxWidth()
.focusRestorer(firstFocus), .focusRestorer(firstFocus),
) { ) {
itemsIndexed(people) { index, item -> itemsIndexed(people, key = { _, item -> item.id }) { index, item ->
PersonCard( PersonCard(
item = item, item = item,
onClick = { onClick.invoke(item) }, onClick = { onClick.invoke(item) },
@ -58,7 +60,8 @@ fun PersonRow(
modifier = modifier =
Modifier Modifier
.width(120.dp) .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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester 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.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -24,6 +25,7 @@ fun FocusedEpisodeFooter(
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
buttonOnFocusChanged: (FocusState) -> Unit = {},
) { ) {
val dto = ep.data val dto = ep.data
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
@ -41,7 +43,7 @@ fun FocusedEpisodeFooter(
moreOnClick = moreOnClick, moreOnClick = moreOnClick,
watchOnClick = watchOnClick, watchOnClick = watchOnClick,
favoriteOnClick = favoriteOnClick, favoriteOnClick = favoriteOnClick,
buttonOnFocusChanged = {}, buttonOnFocusChanged = buttonOnFocusChanged,
modifier = Modifier, modifier = Modifier,
) )
} }

View file

@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
@ -20,6 +22,7 @@ fun FocusedEpisodeHeader(
ep: BaseItem?, ep: BaseItem?,
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
overviewOnFocus: (FocusState) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -44,6 +47,7 @@ fun FocusedEpisodeHeader(
overview = dto?.overview ?: "", overview = dto?.overview ?: "",
maxLines = 3, maxLines = 3,
onClick = overviewOnClick, 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.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.map
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.chooseSource 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.equalsNotNull
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.nav.Destination 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.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaType 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.extensions.ticks
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
@ -85,6 +89,8 @@ fun SeriesOverview(
val context = LocalContext.current val context = LocalContext.current
val firstItemFocusRequester = remember { FocusRequester() } val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() }
val guestStarRowFocusRequester = remember { FocusRequester() }
var initialLoadDone by rememberSaveable { mutableStateOf(false) } var initialLoadDone by rememberSaveable { mutableStateOf(false) }
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
@ -103,6 +109,7 @@ fun SeriesOverview(
val series by viewModel.item.observeAsState(null) val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(listOf())
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading) 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 val episodeList = (episodes as? EpisodeList.Success)?.episodes
var position by rememberSaveable( var position by rememberSaveable(
@ -138,6 +145,8 @@ fun SeriesOverview(
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var rowFocused by rememberInt()
LaunchedEffect(episodes) { LaunchedEffect(episodes) {
episodes?.let { episodes -> episodes?.let { episodes ->
if (episodes is EpisodeList.Success) { if (episodes is EpisodeList.Success) {
@ -152,12 +161,15 @@ fun SeriesOverview(
} }
} }
LaunchedEffect(position) { LaunchedEffect(position, episodes) {
val focusedEpisode =
(episodes as? EpisodeList.Success) (episodes as? EpisodeList.Success)
?.episodes ?.episodes
?.getOrNull(position.episodeRowIndex) ?.getOrNull(position.episodeRowIndex)
?.let {
focusedEpisode?.let {
viewModel.lookUpChosenTracks(it.id, it) viewModel.lookUpChosenTracks(it.id, it)
viewModel.lookupPeopleInEpisode(it)
} }
} }
val chosenStreams by viewModel.chosenStreams.observeAsState(null) val chosenStreams by viewModel.chosenStreams.observeAsState(null)
@ -171,7 +183,14 @@ fun SeriesOverview(
LoadingState.Success -> { LoadingState.Success -> {
series?.let { series -> 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) { LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong( viewModel.maybePlayThemeSong(
destination.itemId, destination.itemId,
@ -263,6 +282,7 @@ fun SeriesOverview(
seasons = seasons, seasons = seasons,
episodes = episodes, episodes = episodes,
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
peopleInEpisode = peopleInEpisode,
position = position, position = position,
backdropImageUrl = backdropImageUrl =
remember { remember {
@ -273,6 +293,8 @@ fun SeriesOverview(
}, },
firstItemFocusRequester = firstItemFocusRequester, firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester, episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester,
guestStarRowFocusRequester = guestStarRowFocusRequester,
onFocus = { onFocus = {
if (it.seasonTabIndex != position.seasonTabIndex) { if (it.seasonTabIndex != position.seasonTabIndex) {
seasons.getOrNull(it.seasonTabIndex)?.let { season -> seasons.getOrNull(it.seasonTabIndex)?.let { season ->
@ -282,6 +304,7 @@ fun SeriesOverview(
position = it position = it
}, },
onClick = { onClick = {
rowFocused = EPISODE_ROW
val resumePosition = val resumePosition =
it.data.userData it.data.userData
?.playbackPositionTicks ?.playbackPositionTicks
@ -298,6 +321,7 @@ fun SeriesOverview(
moreDialog = buildMoreForEpisode(ep, true) moreDialog = buildMoreForEpisode(ep, true)
}, },
playOnClick = { resume -> playOnClick = { resume ->
rowFocused = EPISODE_ROW
episodeList?.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
viewModel.release() viewModel.release()
viewModel.navigateTo( 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, 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 package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState 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.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -24,6 +30,7 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.BannerCard 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.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage 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.ifElse
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.tryRequestFocus 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 import kotlin.time.Duration
@Composable @Composable
@ -60,10 +73,13 @@ fun SeriesOverviewContent(
seasons: List<BaseItem?>, seasons: List<BaseItem?>,
episodes: EpisodeList, episodes: EpisodeList,
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
peopleInEpisode: List<Person>,
position: SeriesOverviewPosition, position: SeriesOverviewPosition,
backdropImageUrl: String?, backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester, firstItemFocusRequester: FocusRequester,
episodeRowFocusRequester: FocusRequester, episodeRowFocusRequester: FocusRequester,
castCrewRowFocusRequester: FocusRequester,
guestStarRowFocusRequester: FocusRequester,
onFocus: (SeriesOverviewPosition) -> Unit, onFocus: (SeriesOverviewPosition) -> Unit,
onClick: (BaseItem) -> Unit, onClick: (BaseItem) -> Unit,
onLongClick: (BaseItem) -> Unit, onLongClick: (BaseItem) -> Unit,
@ -72,8 +88,10 @@ fun SeriesOverviewContent(
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
personOnClick: (Person) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
@ -87,24 +105,32 @@ fun SeriesOverviewContent(
var cardRowHasFocus by remember { mutableStateOf(false) } var cardRowHasFocus by remember { mutableStateOf(false) }
val dimming by animateFloatAsState(if (pageHasFocus && !cardRowHasFocus) .4f else 1f) val dimming by animateFloatAsState(if (pageHasFocus && !cardRowHasFocus) .4f else 1f)
val scrollState = rememberScrollState()
val scrollConnection = rememberDelayedNestedScroll()
Box( Box(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth(),
// .fillMaxHeight(.33f)
.height(460.dp)
.bringIntoViewRequester(bringIntoViewRequester),
) { ) {
DetailsBackdropImage(backdropImageUrl) DetailsBackdropImage(backdropImageUrl)
LazyColumn( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
modifier = modifier =
Modifier Modifier
.focusRestorer(episodeRowFocusRequester) .fillMaxSize()
.padding(16.dp)
.focusGroup()
.nestedScroll(scrollConnection)
.verticalScroll(scrollState)
.onFocusChanged { pageHasFocus = it.hasFocus }, .onFocusChanged { pageHasFocus = it.hasFocus },
) { ) {
item { Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.focusGroup()
.bringIntoViewRequester(bringIntoViewRequester),
) {
val paddingValues = val paddingValues =
if (preferences.appPreferences.interfacePreferences.showClock) { if (preferences.appPreferences.interfacePreferences.showClock) {
PaddingValues(start = 16.dp, end = 100.dp) PaddingValues(start = 16.dp, end = 100.dp)
@ -129,21 +155,22 @@ fun SeriesOverviewContent(
.padding(paddingValues) .padding(paddingValues)
.fillMaxWidth(), .fillMaxWidth(),
) )
}
item {
SeriesName(series.name, Modifier) SeriesName(series.name, Modifier)
}
item {
// Episode header
FocusedEpisodeHeader( FocusedEpisodeHeader(
preferences = preferences, preferences = preferences,
ep = focusedEpisode, ep = focusedEpisode,
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
overviewOnClick = overviewOnClick, overviewOnClick = overviewOnClick,
overviewOnFocus = {
if (it.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
}
},
modifier = Modifier.fillMaxWidth(.6f), modifier = Modifier.fillMaxWidth(.6f),
) )
}
item {
key(position.seasonTabIndex) { key(position.seasonTabIndex) {
when (val eps = episodes) { when (val eps = episodes) {
EpisodeList.Loading -> LoadingPage() EpisodeList.Loading -> LoadingPage()
@ -216,7 +243,13 @@ fun SeriesOverviewContent(
Color.Black, Color.Black,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
).alpha(dimming), ).alpha(dimming),
), ).onFocusChanged {
if (it.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
}
},
interactionSource = interactionSource, interactionSource = interactionSource,
cardHeight = 120.dp, cardHeight = 120.dp,
) )
@ -225,8 +258,7 @@ fun SeriesOverviewContent(
} }
} }
} }
}
item {
focusedEpisode?.let { ep -> focusedEpisode?.let { ep ->
FocusedEpisodeFooter( FocusedEpisodeFooter(
preferences = preferences, preferences = preferences,
@ -239,6 +271,13 @@ fun SeriesOverviewContent(
episodeRowFocusRequester.tryRequestFocus() episodeRowFocusRequester.tryRequestFocus()
}, },
favoriteOnClick = favoriteOnClick, favoriteOnClick = favoriteOnClick,
buttonOnFocusChanged = {
if (it.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
}
},
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .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.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchIO 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.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
@ -37,6 +38,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -80,6 +82,8 @@ class SeriesViewModel
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
fun init( fun init(
prefs: UserPreferences, prefs: UserPreferences,
itemId: UUID, itemId: UUID,
@ -218,6 +222,7 @@ class SeriesViewModel
ItemFields.CUSTOM_RATING, ItemFields.CUSTOM_RATING,
ItemFields.TRICKPLAY, ItemFields.TRICKPLAY,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.PEOPLE,
), ),
) )
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
@ -237,11 +242,15 @@ class SeriesViewModel
0 0
} }
Timber.v("Loaded ${pager.size} episodes for season $seasonId, initialIndex=$initialIndex") 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) { 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 this@SeriesViewModel.episodes.value = EpisodeList.Loading
}
viewModelScope.launchIO(ExceptionHandler(true)) { viewModelScope.launchIO(ExceptionHandler(true)) {
val episodes = val episodes =
try { try {
@ -253,6 +262,12 @@ class SeriesViewModel
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@SeriesViewModel.episodes.value = episodes 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 { sealed interface EpisodeList {
@ -416,7 +449,13 @@ sealed interface EpisodeList {
} }
data class Success( data class Success(
val seasonId: UUID,
val episodes: ApiRequestPager<GetEpisodesRequest>, val episodes: ApiRequestPager<GetEpisodesRequest>,
val initialIndex: Int, val initialIndex: Int,
) : EpisodeList ) : 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="aspect_ratio">Aspect Ratio</string>
<string name="show_details">Show details</string> <string name="show_details">Show details</string>
<string name="image_type">Image type</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"> <string-array name="theme_song_volume">