mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Optimize series data loading speed (#567)
## Description Optimizes how both series pages are loaded. If the series has a lot of seasons and/or lots of episodes per season, the pages load much faster. This is a combination of reducing queried information, optimizing finding the right data to display first, and lazy loading seasons. Note: Series that don't have episode numbers, like daily TV shows, are not as optimized yet ### Rough speed-ups These numbers are from my (beefy) server, so it will vary based on your server Series details page (one with seasons row) w/ ~50 seasons: 5s->300ms, ~13x faster Series overview page (one with episode row) w/ ~50 seasons & 25 episodes: 6s->1.1s, ~5x faster ### Related issues Related to #562
This commit is contained in:
parent
b671e14826
commit
2ecc3f262d
6 changed files with 297 additions and 214 deletions
|
|
@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
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
|
||||||
|
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.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -49,8 +50,8 @@ fun TabRow(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(selectedTabIndex) {
|
||||||
state.animateScrollToItem(selectedTabIndex)
|
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
|
||||||
}
|
}
|
||||||
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
|
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
|
||||||
var rowHasFocus by remember { mutableStateOf(false) }
|
var rowHasFocus by remember { mutableStateOf(false) }
|
||||||
|
|
|
||||||
|
|
@ -88,13 +88,15 @@ fun SeriesDetails(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination.MediaItem,
|
destination: Destination.MediaItem,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
viewModel: SeriesViewModel =
|
||||||
|
hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(
|
||||||
|
creationCallback = {
|
||||||
|
it.create(destination.itemId, null, SeriesPageType.DETAILS)
|
||||||
|
},
|
||||||
|
),
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.init(preferences, destination.itemId, null, true)
|
|
||||||
}
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
|
||||||
val item by viewModel.item.observeAsState()
|
val item by viewModel.item.observeAsState()
|
||||||
|
|
@ -285,7 +287,7 @@ private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||||
fun SeriesDetailsContent(
|
fun SeriesDetailsContent(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
series: BaseItem,
|
series: BaseItem,
|
||||||
seasons: List<BaseItem>,
|
seasons: List<BaseItem?>,
|
||||||
similar: List<BaseItem>,
|
similar: List<BaseItem>,
|
||||||
trailers: List<Trailer>,
|
trailers: List<Trailer>,
|
||||||
extras: List<ExtrasItem>,
|
extras: List<ExtrasItem>,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ package com.github.damontecres.wholphin.ui.detail.series
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.Saver
|
|
||||||
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
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -22,7 +21,6 @@ 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.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -36,13 +34,12 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
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.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
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.coroutines.flow.update
|
||||||
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.BaseItemKind
|
||||||
|
|
@ -52,7 +49,6 @@ 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
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -80,10 +76,15 @@ data class SeriesOverviewPosition(
|
||||||
fun SeriesOverview(
|
fun SeriesOverview(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination.SeriesOverview,
|
destination: Destination.SeriesOverview,
|
||||||
|
initialSeasonEpisode: SeasonEpisodeIds?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
viewModel: SeriesViewModel =
|
||||||
|
hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(
|
||||||
|
creationCallback = {
|
||||||
|
it.create(destination.itemId, initialSeasonEpisode, SeriesPageType.OVERVIEW)
|
||||||
|
},
|
||||||
|
),
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
initialSeasonEpisode: SeasonEpisodeIds? = null,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val firstItemFocusRequester = remember { FocusRequester() }
|
val firstItemFocusRequester = remember { FocusRequester() }
|
||||||
|
|
@ -91,18 +92,6 @@ fun SeriesOverview(
|
||||||
val castCrewRowFocusRequester = remember { FocusRequester() }
|
val castCrewRowFocusRequester = remember { FocusRequester() }
|
||||||
val guestStarRowFocusRequester = remember { FocusRequester() }
|
val guestStarRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
var initialLoadDone by rememberSaveable { mutableStateOf(false) }
|
|
||||||
OneTimeLaunchedEffect {
|
|
||||||
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
|
|
||||||
viewModel.init(
|
|
||||||
preferences,
|
|
||||||
destination.itemId,
|
|
||||||
initialSeasonEpisode,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
initialLoadDone = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
|
||||||
val series by viewModel.item.observeAsState(null)
|
val series by viewModel.item.observeAsState(null)
|
||||||
|
|
@ -111,27 +100,9 @@ fun SeriesOverview(
|
||||||
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf())
|
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(
|
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
|
||||||
destination,
|
LaunchedEffect(Unit) {
|
||||||
loading,
|
if (seasons.isNotEmpty()) {
|
||||||
stateSaver =
|
|
||||||
Saver(
|
|
||||||
save = { listOf(it.seasonTabIndex, it.episodeRowIndex) },
|
|
||||||
restore = { SeriesOverviewPosition(it[0], it[1]) },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
mutableStateOf(
|
|
||||||
SeriesOverviewPosition(
|
|
||||||
seasons.indexOfFirstOrNull {
|
|
||||||
equalsNotNull(it.id, initialSeasonEpisode?.seasonId) ||
|
|
||||||
equalsNotNull(it.indexNumber, initialSeasonEpisode?.seasonNumber)
|
|
||||||
} ?: 0,
|
|
||||||
(episodes as? EpisodeList.Success)?.initialIndex ?: 0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (initialLoadDone) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
seasons.getOrNull(position.seasonTabIndex)?.let {
|
seasons.getOrNull(position.seasonTabIndex)?.let {
|
||||||
viewModel.loadEpisodes(it.id)
|
viewModel.loadEpisodes(it.id)
|
||||||
}
|
}
|
||||||
|
|
@ -301,13 +272,20 @@ fun SeriesOverview(
|
||||||
episodeRowFocusRequester = episodeRowFocusRequester,
|
episodeRowFocusRequester = episodeRowFocusRequester,
|
||||||
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
||||||
guestStarRowFocusRequester = guestStarRowFocusRequester,
|
guestStarRowFocusRequester = guestStarRowFocusRequester,
|
||||||
onFocus = {
|
onChangeSeason = { index ->
|
||||||
if (it.seasonTabIndex != position.seasonTabIndex) {
|
if (index != position.seasonTabIndex) {
|
||||||
seasons.getOrNull(it.seasonTabIndex)?.let { season ->
|
seasons.getOrNull(index)?.let { season ->
|
||||||
viewModel.loadEpisodes(season.id)
|
viewModel.loadEpisodes(season.id)
|
||||||
|
viewModel.position.update {
|
||||||
|
SeriesOverviewPosition(index, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
position = it
|
},
|
||||||
|
onFocusEpisode = { episodeIndex ->
|
||||||
|
viewModel.position.update {
|
||||||
|
it.copy(episodeRowIndex = episodeIndex)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
rowFocused = EPISODE_ROW
|
rowFocused = EPISODE_ROW
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ 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
|
||||||
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
|
||||||
|
|
@ -80,7 +79,8 @@ fun SeriesOverviewContent(
|
||||||
episodeRowFocusRequester: FocusRequester,
|
episodeRowFocusRequester: FocusRequester,
|
||||||
castCrewRowFocusRequester: FocusRequester,
|
castCrewRowFocusRequester: FocusRequester,
|
||||||
guestStarRowFocusRequester: FocusRequester,
|
guestStarRowFocusRequester: FocusRequester,
|
||||||
onFocus: (SeriesOverviewPosition) -> Unit,
|
onChangeSeason: (Int) -> Unit,
|
||||||
|
onFocusEpisode: (Int) -> Unit,
|
||||||
onClick: (BaseItem) -> Unit,
|
onClick: (BaseItem) -> Unit,
|
||||||
onLongClick: (BaseItem) -> Unit,
|
onLongClick: (BaseItem) -> Unit,
|
||||||
playOnClick: (Duration) -> Unit,
|
playOnClick: (Duration) -> Unit,
|
||||||
|
|
@ -141,11 +141,12 @@ fun SeriesOverviewContent(
|
||||||
tabs =
|
tabs =
|
||||||
seasons.mapNotNull {
|
seasons.mapNotNull {
|
||||||
it?.name
|
it?.name
|
||||||
?: (stringResource(R.string.tv_season) + " ${it?.data?.indexNumber}")
|
?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" }
|
||||||
|
?: ""
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedTabIndex = it
|
selectedTabIndex = it
|
||||||
onFocus.invoke(SeriesOverviewPosition(it, 0))
|
onChangeSeason.invoke(it)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -169,102 +170,97 @@ fun SeriesOverviewContent(
|
||||||
modifier = Modifier.fillMaxWidth(.6f),
|
modifier = Modifier.fillMaxWidth(.6f),
|
||||||
)
|
)
|
||||||
|
|
||||||
key(position.seasonTabIndex) {
|
// key(position.seasonTabIndex) {
|
||||||
when (val eps = episodes) {
|
when (val eps = episodes) {
|
||||||
EpisodeList.Loading -> {
|
EpisodeList.Loading -> {
|
||||||
LoadingPage()
|
LoadingPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
is EpisodeList.Error -> {
|
is EpisodeList.Error -> {
|
||||||
ErrorMessage(eps.message, eps.exception)
|
ErrorMessage(eps.message, eps.exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
is EpisodeList.Success -> {
|
is EpisodeList.Success -> {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
OneTimeLaunchedEffect {
|
OneTimeLaunchedEffect {
|
||||||
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
|
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
|
||||||
state.scrollToItem(position.episodeRowIndex)
|
state.scrollToItem(position.episodeRowIndex)
|
||||||
}
|
|
||||||
firstItemFocusRequester.tryRequestFocus()
|
|
||||||
}
|
}
|
||||||
LazyRow(
|
firstItemFocusRequester.tryRequestFocus()
|
||||||
state = state,
|
}
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
LazyRow(
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
state = state,
|
||||||
modifier =
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
Modifier
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
.focusRestorer(firstItemFocusRequester)
|
modifier =
|
||||||
.focusRequester(episodeRowFocusRequester)
|
Modifier
|
||||||
.onFocusChanged {
|
.focusRestorer(firstItemFocusRequester)
|
||||||
cardRowHasFocus = it.hasFocus
|
.focusRequester(episodeRowFocusRequester)
|
||||||
},
|
.onFocusChanged {
|
||||||
) {
|
cardRowHasFocus = it.hasFocus
|
||||||
itemsIndexed(eps.episodes) { episodeIndex, episode ->
|
},
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
) {
|
||||||
if (interactionSource.collectIsFocusedAsState().value) {
|
itemsIndexed(eps.episodes) { episodeIndex, episode ->
|
||||||
onFocus.invoke(
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
SeriesOverviewPosition(
|
if (interactionSource.collectIsFocusedAsState().value) {
|
||||||
selectedTabIndex,
|
onFocusEpisode.invoke(episodeIndex)
|
||||||
episodeIndex,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val cornerText =
|
|
||||||
episode?.data?.indexNumber?.let { "E$it" }
|
|
||||||
?: episode?.data?.premiereDate?.let(::formatDateTime)
|
|
||||||
BannerCard(
|
|
||||||
name = episode?.name,
|
|
||||||
item = episode,
|
|
||||||
aspectRatio =
|
|
||||||
episode
|
|
||||||
?.aspectRatio
|
|
||||||
?.coerceAtLeast(AspectRatios.FOUR_THREE)
|
|
||||||
?: (AspectRatios.WIDE),
|
|
||||||
cornerText = cornerText,
|
|
||||||
played = episode?.data?.userData?.played ?: false,
|
|
||||||
playPercent =
|
|
||||||
episode?.data?.userData?.playedPercentage
|
|
||||||
?: 0.0,
|
|
||||||
onClick = { if (episode != null) onClick.invoke(episode) },
|
|
||||||
onLongClick = {
|
|
||||||
if (episode != null) {
|
|
||||||
onLongClick.invoke(
|
|
||||||
episode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.ifElse(
|
|
||||||
episodeIndex == position.episodeRowIndex,
|
|
||||||
Modifier.focusRequester(firstItemFocusRequester),
|
|
||||||
).ifElse(
|
|
||||||
episodeIndex != position.episodeRowIndex,
|
|
||||||
Modifier
|
|
||||||
.background(
|
|
||||||
Color.Black,
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
).alpha(dimming),
|
|
||||||
).onFocusChanged {
|
|
||||||
if (it.isFocused) {
|
|
||||||
scope.launch {
|
|
||||||
bringIntoViewRequester.bringIntoView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onKeyEvent {
|
|
||||||
if (episode != null && isPlayKeyUp(it)) {
|
|
||||||
onClick.invoke(episode)
|
|
||||||
return@onKeyEvent true
|
|
||||||
}
|
|
||||||
return@onKeyEvent false
|
|
||||||
},
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
cardHeight = 120.dp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
val cornerText =
|
||||||
|
episode?.data?.indexNumber?.let { "E$it" }
|
||||||
|
?: episode?.data?.premiereDate?.let(::formatDateTime)
|
||||||
|
BannerCard(
|
||||||
|
name = episode?.name,
|
||||||
|
item = episode,
|
||||||
|
aspectRatio =
|
||||||
|
episode
|
||||||
|
?.aspectRatio
|
||||||
|
?.coerceAtLeast(AspectRatios.FOUR_THREE)
|
||||||
|
?: (AspectRatios.WIDE),
|
||||||
|
cornerText = cornerText,
|
||||||
|
played = episode?.data?.userData?.played ?: false,
|
||||||
|
playPercent =
|
||||||
|
episode?.data?.userData?.playedPercentage
|
||||||
|
?: 0.0,
|
||||||
|
onClick = { if (episode != null) onClick.invoke(episode) },
|
||||||
|
onLongClick = {
|
||||||
|
if (episode != null) {
|
||||||
|
onLongClick.invoke(
|
||||||
|
episode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.ifElse(
|
||||||
|
episodeIndex == position.episodeRowIndex,
|
||||||
|
Modifier.focusRequester(firstItemFocusRequester),
|
||||||
|
).ifElse(
|
||||||
|
episodeIndex != position.episodeRowIndex,
|
||||||
|
Modifier
|
||||||
|
.background(
|
||||||
|
Color.Black,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
).alpha(dimming),
|
||||||
|
).onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onKeyEvent {
|
||||||
|
if (episode != null && isPlayKeyUp(it)) {
|
||||||
|
onClick.invoke(episode)
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
return@onKeyEvent false
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
cardHeight = 120.dp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
focusedEpisode?.let { ep ->
|
focusedEpisode?.let { ep ->
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,10 @@ import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
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.gt
|
||||||
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.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.lt
|
||||||
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,11 +39,19 @@ import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
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
|
||||||
|
|
@ -57,11 +67,10 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class)
|
||||||
class SeriesViewModel
|
class SeriesViewModel
|
||||||
@Inject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
@param:ApplicationContext val context: Context,
|
@param:ApplicationContext val context: Context,
|
||||||
|
|
@ -76,11 +85,22 @@ class SeriesViewModel
|
||||||
val streamChoiceService: StreamChoiceService,
|
val streamChoiceService: StreamChoiceService,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
|
@Assisted val seriesId: UUID,
|
||||||
|
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||||
|
@Assisted val seriesPageType: SeriesPageType,
|
||||||
) : ItemViewModel(api) {
|
) : ItemViewModel(api) {
|
||||||
private lateinit var seriesId: UUID
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(
|
||||||
|
seriesId: UUID,
|
||||||
|
seasonEpisodeIds: SeasonEpisodeIds?,
|
||||||
|
seriesPageType: SeriesPageType,
|
||||||
|
): SeriesViewModel
|
||||||
|
}
|
||||||
|
|
||||||
private lateinit var prefs: UserPreferences
|
private lateinit var prefs: UserPreferences
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
val seasons = MutableLiveData<List<BaseItem>>(listOf())
|
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
|
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
|
||||||
|
|
||||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||||
|
|
@ -90,48 +110,77 @@ class SeriesViewModel
|
||||||
|
|
||||||
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
||||||
|
|
||||||
fun init(
|
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
||||||
prefs: UserPreferences,
|
|
||||||
itemId: UUID,
|
init {
|
||||||
seasonEpisodeIds: SeasonEpisodeIds?,
|
|
||||||
loadAdditionalDetails: Boolean,
|
|
||||||
) {
|
|
||||||
this.seriesId = itemId
|
|
||||||
this.prefs = prefs
|
|
||||||
viewModelScope.launch(
|
viewModelScope.launch(
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
loading,
|
loading,
|
||||||
"Error loading series $seriesId",
|
"Error loading series $seriesId",
|
||||||
) + Dispatchers.IO,
|
) + Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
|
this@SeriesViewModel.prefs = userPreferencesService.getCurrent()
|
||||||
|
Timber.v("Start")
|
||||||
val item = fetchItem(seriesId)
|
val item = fetchItem(seriesId)
|
||||||
backdropService.submit(item)
|
backdropService.submit(item)
|
||||||
val seasons = getSeasons(item)
|
|
||||||
|
|
||||||
// If a particular season was requested, fetch those episodes, otherwise get the first season
|
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
||||||
val initialSeason =
|
|
||||||
if (seasonEpisodeIds != null) {
|
val episodeListDeferred =
|
||||||
seasons.firstOrNull {
|
if (seriesPageType == SeriesPageType.OVERVIEW) {
|
||||||
equalsNotNull(it.id, seasonEpisodeIds.seasonId) ||
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
equalsNotNull(it.indexNumber, seasonEpisodeIds.seasonNumber)
|
if (seasonEpisodeIds != null) {
|
||||||
|
loadEpisodesInternal(
|
||||||
|
seasonEpisodeIds.seasonId,
|
||||||
|
seasonEpisodeIds.episodeId,
|
||||||
|
seasonEpisodeIds.episodeNumber,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
seasonsDeferred.await().firstOrNull()?.let {
|
||||||
|
loadEpisodesInternal(
|
||||||
|
it.id,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
} ?: EpisodeList.Error(message = "Could not determine season")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
seasons.firstOrNull()
|
CompletableDeferred(value = EpisodeList.Loading)
|
||||||
}
|
}
|
||||||
val episodeInfo =
|
val seasons = seasonsDeferred.await()
|
||||||
initialSeason?.let {
|
val episodes = episodeListDeferred.await()
|
||||||
loadEpisodesInternal(
|
Timber.v("Done")
|
||||||
it.id,
|
|
||||||
seasonEpisodeIds?.episodeId,
|
if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) {
|
||||||
seasonEpisodeIds?.episodeNumber,
|
viewModelScope.launchIO {
|
||||||
)
|
val index =
|
||||||
} ?: EpisodeList.Error("Could not determine season for selected episode")
|
(seasons as? ApiRequestPager<*>)?.let {
|
||||||
|
findIndexOf(
|
||||||
|
seasonEpisodeIds.seasonNumber,
|
||||||
|
seasonEpisodeIds.seasonId,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
} ?: 0
|
||||||
|
Timber.v("Got initial season index: $index")
|
||||||
|
position.update {
|
||||||
|
it.copy(seasonTabIndex = index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
this@SeriesViewModel.position.update {
|
||||||
|
it.copy(
|
||||||
|
episodeRowIndex =
|
||||||
|
(episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
this@SeriesViewModel.seasons.value = seasons
|
this@SeriesViewModel.seasons.value = seasons
|
||||||
episodes.value = episodeInfo
|
this@SeriesViewModel.episodes.value = episodes
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
if (loadAdditionalDetails) {
|
if (seriesPageType == SeriesPageType.DETAILS) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val trailers = trailerService.getTrailers(item)
|
val trailers = trailerService.getTrailers(item)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -153,7 +202,7 @@ class SeriesViewModel
|
||||||
.getSimilarItems(
|
.getSimilarItems(
|
||||||
GetSimilarItemsRequest(
|
GetSimilarItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser.value?.id,
|
||||||
itemId = itemId,
|
itemId = seriesId,
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
limit = 25,
|
limit = 25,
|
||||||
),
|
),
|
||||||
|
|
@ -185,31 +234,45 @@ class SeriesViewModel
|
||||||
themeSongPlayer.stop()
|
themeSongPlayer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSeasons(series: BaseItem): List<BaseItem> {
|
private fun getSeasons(
|
||||||
val request =
|
series: BaseItem,
|
||||||
GetItemsRequest(
|
seasonNum: Int?,
|
||||||
parentId = series.id,
|
): Deferred<List<BaseItem?>> =
|
||||||
recursive = false,
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
includeItemTypes = listOf(BaseItemKind.SEASON),
|
val request =
|
||||||
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
|
GetItemsRequest(
|
||||||
sortOrder = listOf(SortOrder.ASCENDING),
|
parentId = series.id,
|
||||||
fields =
|
recursive = false,
|
||||||
listOf(
|
includeItemTypes = listOf(BaseItemKind.SEASON),
|
||||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
|
||||||
ItemFields.CHILD_COUNT,
|
sortOrder = listOf(SortOrder.ASCENDING),
|
||||||
ItemFields.SEASON_USER_DATA,
|
fields =
|
||||||
),
|
if (seriesPageType == SeriesPageType.DETAILS) {
|
||||||
)
|
listOf(
|
||||||
val seasons =
|
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||||
GetItemsRequestHandler.execute(api, request).content.items.map {
|
)
|
||||||
BaseItem.from(
|
} else {
|
||||||
it,
|
null
|
||||||
api,
|
},
|
||||||
)
|
)
|
||||||
}
|
val pager =
|
||||||
Timber.v("Loaded ${seasons.size} seasons for series ${series.id}")
|
ApiRequestPager(
|
||||||
return seasons
|
api,
|
||||||
}
|
request,
|
||||||
|
GetItemsRequestHandler,
|
||||||
|
viewModelScope,
|
||||||
|
pageSize = 10,
|
||||||
|
).init(seasonNum ?: 0)
|
||||||
|
// val seasons =
|
||||||
|
// GetItemsRequestHandler.execute(api, request).content.items.map {
|
||||||
|
// BaseItem.from(
|
||||||
|
// it,
|
||||||
|
// api,
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// Timber.v("Loaded ${seasons.size} seasons for series ${series.id}")
|
||||||
|
pager
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadEpisodesInternal(
|
private suspend fun loadEpisodesInternal(
|
||||||
seasonId: UUID,
|
seasonId: UUID,
|
||||||
|
|
@ -233,14 +296,11 @@ class SeriesViewModel
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
||||||
pager.init()
|
pager.init(episodeNumber ?: 0)
|
||||||
val initialIndex =
|
val initialIndex =
|
||||||
if (episodeId != null || episodeNumber != null) {
|
if (episodeId != null || episodeNumber != null) {
|
||||||
pager
|
findIndexOf(episodeNumber, episodeId, pager)
|
||||||
.indexOfBlocking {
|
.coerceAtLeast(0)
|
||||||
equalsNotNull(it?.id, episodeId) ||
|
|
||||||
equalsNotNull(it?.indexNumber, episodeNumber)
|
|
||||||
}.coerceAtLeast(0)
|
|
||||||
} else {
|
} else {
|
||||||
// Force the first page to to be fetched
|
// Force the first page to to be fetched
|
||||||
if (pager.isNotEmpty()) {
|
if (pager.isNotEmpty()) {
|
||||||
|
|
@ -272,7 +332,7 @@ class SeriesViewModel
|
||||||
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
||||||
(episodes as? EpisodeList.Success)
|
(episodes as? EpisodeList.Success)
|
||||||
?.let {
|
?.let {
|
||||||
it.episodes.getOrNull(it.initialIndex)
|
it.episodes.getOrNull(it.initialEpisodeIndex)
|
||||||
}?.let { lookupPeopleInEpisode(it) }
|
}?.let { lookupPeopleInEpisode(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -312,7 +372,7 @@ class SeriesViewModel
|
||||||
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
setWatched(seasonId, played, null)
|
setWatched(seasonId, played, null)
|
||||||
val series = fetchItem(seriesId)
|
val series = fetchItem(seriesId)
|
||||||
val seasons = getSeasons(series)
|
val seasons = getSeasons(series, null).await()
|
||||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,7 +380,7 @@ class SeriesViewModel
|
||||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
favoriteWatchManager.setWatched(seriesId, played)
|
favoriteWatchManager.setWatched(seriesId, played)
|
||||||
val series = fetchItem(seriesId)
|
val series = fetchItem(seriesId)
|
||||||
val seasons = getSeasons(series)
|
val seasons = getSeasons(series, null).await()
|
||||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -469,7 +529,7 @@ sealed interface EpisodeList {
|
||||||
data class Success(
|
data class Success(
|
||||||
val seasonId: UUID,
|
val seasonId: UUID,
|
||||||
val episodes: ApiRequestPager<GetEpisodesRequest>,
|
val episodes: ApiRequestPager<GetEpisodesRequest>,
|
||||||
val initialIndex: Int,
|
val initialEpisodeIndex: Int,
|
||||||
) : EpisodeList
|
) : EpisodeList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,3 +537,49 @@ data class PeopleInItem(
|
||||||
val itemId: UUID? = null,
|
val itemId: UUID? = null,
|
||||||
val people: List<Person> = listOf(),
|
val people: List<Person> = listOf(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class SeriesPageType {
|
||||||
|
DETAILS,
|
||||||
|
OVERVIEW,
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun findIndexOf(
|
||||||
|
targetNum: Int?,
|
||||||
|
targetId: UUID?,
|
||||||
|
pager: ApiRequestPager<*>,
|
||||||
|
): Int {
|
||||||
|
val index =
|
||||||
|
if (targetId != null && (targetNum == null || targetNum !in pager.indices)) {
|
||||||
|
// No hint info, so have to check everything
|
||||||
|
pager.indexOfBlocking { equalsNotNull(it?.id, targetId) }
|
||||||
|
} else if (targetNum != null && targetNum in pager.indices) {
|
||||||
|
// Start searching from the season number and choose direction from there
|
||||||
|
val num = pager.getBlocking(targetNum)?.indexNumber
|
||||||
|
if (num.lt(targetNum)) {
|
||||||
|
for (i in targetNum + 1 until pager.lastIndex) {
|
||||||
|
val season = pager.getBlocking(i)
|
||||||
|
if (equalsNotNull(season?.indexNumber, targetNum) ||
|
||||||
|
equalsNotNull(season?.id, targetId)
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} else if (num.gt(targetNum)) {
|
||||||
|
for (i in targetNum - 1 downTo 0) {
|
||||||
|
val season = pager.getBlocking(i)
|
||||||
|
if (equalsNotNull(season?.indexNumber, targetNum) ||
|
||||||
|
equalsNotNull(season?.id, targetId)
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} else {
|
||||||
|
targetNum
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,10 @@ fun DestinationContent(
|
||||||
|
|
||||||
is Destination.SeriesOverview -> {
|
is Destination.SeriesOverview -> {
|
||||||
SeriesOverview(
|
SeriesOverview(
|
||||||
preferences,
|
preferences = preferences,
|
||||||
destination,
|
destination = destination,
|
||||||
modifier,
|
|
||||||
initialSeasonEpisode = destination.seasonEpisode,
|
initialSeasonEpisode = destination.seasonEpisode,
|
||||||
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue