mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +02:00
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 
This commit is contained in:
parent
8ea84b3efe
commit
887b7b597e
8 changed files with 263 additions and 34 deletions
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue