From 2ecc3f262d5aa57991d5fecf0efd889f0a258d98 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:47:17 -0500 Subject: [PATCH] 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 --- .../wholphin/ui/components/TabRow.kt | 5 +- .../ui/detail/series/SeriesDetails.kt | 12 +- .../ui/detail/series/SeriesOverview.kt | 68 ++--- .../ui/detail/series/SeriesOverviewContent.kt | 182 +++++++------- .../ui/detail/series/SeriesViewModel.kt | 238 +++++++++++++----- .../wholphin/ui/nav/DestinationContent.kt | 6 +- 6 files changed, 297 insertions(+), 214 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index 89323bdd..16e26649 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -49,8 +50,8 @@ fun TabRow( modifier: Modifier = Modifier, ) { val state = rememberLazyListState() - LaunchedEffect(Unit) { - state.animateScrollToItem(selectedTabIndex) + LaunchedEffect(selectedTabIndex) { + state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) } val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } var rowHasFocus by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 0ab81b7e..43fef40a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -88,13 +88,15 @@ fun SeriesDetails( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: SeriesViewModel = hiltViewModel(), + viewModel: SeriesViewModel = + hiltViewModel( + creationCallback = { + it.create(destination.itemId, null, SeriesPageType.DETAILS) + }, + ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LaunchedEffect(Unit) { - viewModel.init(preferences, destination.itemId, null, true) - } val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() @@ -285,7 +287,7 @@ private const val SIMILAR_ROW = EXTRAS_ROW + 1 fun SeriesDetailsContent( preferences: UserPreferences, series: BaseItem, - seasons: List, + seasons: List, similar: List, trailers: List, extras: List, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 73f489c1..9377aa1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -4,12 +4,11 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf 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.ui.Modifier 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.data.model.BaseItem 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.DialogPopup 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.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems -import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers 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.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -80,10 +76,15 @@ data class SeriesOverviewPosition( fun SeriesOverview( preferences: UserPreferences, destination: Destination.SeriesOverview, + initialSeasonEpisode: SeasonEpisodeIds?, modifier: Modifier = Modifier, - viewModel: SeriesViewModel = hiltViewModel(), + viewModel: SeriesViewModel = + hiltViewModel( + creationCallback = { + it.create(destination.itemId, initialSeasonEpisode, SeriesPageType.OVERVIEW) + }, + ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), - initialSeasonEpisode: SeasonEpisodeIds? = null, ) { val context = LocalContext.current val firstItemFocusRequester = remember { FocusRequester() } @@ -91,18 +92,6 @@ fun SeriesOverview( val castCrewRowFocusRequester = 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 series by viewModel.item.observeAsState(null) @@ -111,27 +100,9 @@ fun SeriesOverview( val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf()) val episodeList = (episodes as? EpisodeList.Success)?.episodes - var position by rememberSaveable( - destination, - loading, - 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) { + val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) + LaunchedEffect(Unit) { + if (seasons.isNotEmpty()) { seasons.getOrNull(position.seasonTabIndex)?.let { viewModel.loadEpisodes(it.id) } @@ -301,13 +272,20 @@ fun SeriesOverview( episodeRowFocusRequester = episodeRowFocusRequester, castCrewRowFocusRequester = castCrewRowFocusRequester, guestStarRowFocusRequester = guestStarRowFocusRequester, - onFocus = { - if (it.seasonTabIndex != position.seasonTabIndex) { - seasons.getOrNull(it.seasonTabIndex)?.let { season -> + onChangeSeason = { index -> + if (index != position.seasonTabIndex) { + seasons.getOrNull(index)?.let { season -> viewModel.loadEpisodes(season.id) + viewModel.position.update { + SeriesOverviewPosition(index, 0) + } } } - position = it + }, + onFocusEpisode = { episodeIndex -> + viewModel.position.update { + it.copy(episodeRowIndex = episodeIndex) + } }, onClick = { rowFocused = EPISODE_ROW diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 9a2d4dcf..3cf1f598 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -80,7 +79,8 @@ fun SeriesOverviewContent( episodeRowFocusRequester: FocusRequester, castCrewRowFocusRequester: FocusRequester, guestStarRowFocusRequester: FocusRequester, - onFocus: (SeriesOverviewPosition) -> Unit, + onChangeSeason: (Int) -> Unit, + onFocusEpisode: (Int) -> Unit, onClick: (BaseItem) -> Unit, onLongClick: (BaseItem) -> Unit, playOnClick: (Duration) -> Unit, @@ -141,11 +141,12 @@ fun SeriesOverviewContent( tabs = seasons.mapNotNull { it?.name - ?: (stringResource(R.string.tv_season) + " ${it?.data?.indexNumber}") + ?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" } + ?: "" }, onClick = { selectedTabIndex = it - onFocus.invoke(SeriesOverviewPosition(it, 0)) + onChangeSeason.invoke(it) }, modifier = Modifier @@ -169,102 +170,97 @@ fun SeriesOverviewContent( modifier = Modifier.fillMaxWidth(.6f), ) - key(position.seasonTabIndex) { - when (val eps = episodes) { - EpisodeList.Loading -> { - LoadingPage() - } +// key(position.seasonTabIndex) { + when (val eps = episodes) { + EpisodeList.Loading -> { + LoadingPage() + } - is EpisodeList.Error -> { - ErrorMessage(eps.message, eps.exception) - } + is EpisodeList.Error -> { + ErrorMessage(eps.message, eps.exception) + } - is EpisodeList.Success -> { - val state = rememberLazyListState() - OneTimeLaunchedEffect { - if (state.firstVisibleItemIndex != position.episodeRowIndex) { - state.scrollToItem(position.episodeRowIndex) - } - firstItemFocusRequester.tryRequestFocus() + is EpisodeList.Success -> { + val state = rememberLazyListState() + OneTimeLaunchedEffect { + if (state.firstVisibleItemIndex != position.episodeRowIndex) { + state.scrollToItem(position.episodeRowIndex) } - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - modifier = - Modifier - .focusRestorer(firstItemFocusRequester) - .focusRequester(episodeRowFocusRequester) - .onFocusChanged { - cardRowHasFocus = it.hasFocus - }, - ) { - itemsIndexed(eps.episodes) { episodeIndex, episode -> - val interactionSource = remember { MutableInteractionSource() } - if (interactionSource.collectIsFocusedAsState().value) { - onFocus.invoke( - SeriesOverviewPosition( - selectedTabIndex, - 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, - ) + firstItemFocusRequester.tryRequestFocus() + } + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + modifier = + Modifier + .focusRestorer(firstItemFocusRequester) + .focusRequester(episodeRowFocusRequester) + .onFocusChanged { + cardRowHasFocus = it.hasFocus + }, + ) { + itemsIndexed(eps.episodes) { episodeIndex, episode -> + val interactionSource = remember { MutableInteractionSource() } + if (interactionSource.collectIsFocusedAsState().value) { + onFocusEpisode.invoke(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, + ) } } } +// } } focusedEpisode?.let { ep -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 5dbe62f8..cdcc8c30 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -26,8 +26,10 @@ import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchIO 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.setValueOnMain 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.LoadingExceptionHandler 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.qualifiers.ApplicationContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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 timber.log.Timber import java.util.UUID -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class) class SeriesViewModel - @Inject + @AssistedInject constructor( api: ApiClient, @param:ApplicationContext val context: Context, @@ -76,11 +85,22 @@ class SeriesViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + @Assisted val seriesId: UUID, + @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, + @Assisted val seriesPageType: SeriesPageType, ) : ItemViewModel(api) { - private lateinit var seriesId: UUID + @AssistedFactory + interface Factory { + fun create( + seriesId: UUID, + seasonEpisodeIds: SeasonEpisodeIds?, + seriesPageType: SeriesPageType, + ): SeriesViewModel + } + private lateinit var prefs: UserPreferences val loading = MutableLiveData(LoadingState.Loading) - val seasons = MutableLiveData>(listOf()) + val seasons = MutableLiveData>(listOf()) val episodes = MutableLiveData(EpisodeList.Loading) val trailers = MutableLiveData>(listOf()) @@ -90,48 +110,77 @@ class SeriesViewModel val peopleInEpisode = MutableLiveData(PeopleInItem()) - fun init( - prefs: UserPreferences, - itemId: UUID, - seasonEpisodeIds: SeasonEpisodeIds?, - loadAdditionalDetails: Boolean, - ) { - this.seriesId = itemId - this.prefs = prefs + val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) + + init { viewModelScope.launch( LoadingExceptionHandler( loading, "Error loading series $seriesId", ) + Dispatchers.IO, ) { + this@SeriesViewModel.prefs = userPreferencesService.getCurrent() + Timber.v("Start") val item = fetchItem(seriesId) backdropService.submit(item) - val seasons = getSeasons(item) - // If a particular season was requested, fetch those episodes, otherwise get the first season - val initialSeason = - if (seasonEpisodeIds != null) { - seasons.firstOrNull { - equalsNotNull(it.id, seasonEpisodeIds.seasonId) || - equalsNotNull(it.indexNumber, seasonEpisodeIds.seasonNumber) + val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) + + val episodeListDeferred = + if (seriesPageType == SeriesPageType.OVERVIEW) { + viewModelScope.async(Dispatchers.IO) { + 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 { - seasons.firstOrNull() + CompletableDeferred(value = EpisodeList.Loading) } - val episodeInfo = - initialSeason?.let { - loadEpisodesInternal( - it.id, - seasonEpisodeIds?.episodeId, - seasonEpisodeIds?.episodeNumber, - ) - } ?: EpisodeList.Error("Could not determine season for selected episode") + val seasons = seasonsDeferred.await() + val episodes = episodeListDeferred.await() + Timber.v("Done") + + if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) { + viewModelScope.launchIO { + val index = + (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) { + this@SeriesViewModel.position.update { + it.copy( + episodeRowIndex = + (episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0, + ) + } this@SeriesViewModel.seasons.value = seasons - episodes.value = episodeInfo + this@SeriesViewModel.episodes.value = episodes loading.value = LoadingState.Success } - if (loadAdditionalDetails) { + if (seriesPageType == SeriesPageType.DETAILS) { viewModelScope.launchIO { val trailers = trailerService.getTrailers(item) withContext(Dispatchers.Main) { @@ -153,7 +202,7 @@ class SeriesViewModel .getSimilarItems( GetSimilarItemsRequest( userId = serverRepository.currentUser.value?.id, - itemId = itemId, + itemId = seriesId, fields = SlimItemFields, limit = 25, ), @@ -185,31 +234,45 @@ class SeriesViewModel themeSongPlayer.stop() } - private suspend fun getSeasons(series: BaseItem): List { - val request = - GetItemsRequest( - parentId = series.id, - recursive = false, - includeItemTypes = listOf(BaseItemKind.SEASON), - sortBy = listOf(ItemSortBy.INDEX_NUMBER), - sortOrder = listOf(SortOrder.ASCENDING), - fields = - listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.CHILD_COUNT, - ItemFields.SEASON_USER_DATA, - ), - ) - val seasons = - GetItemsRequestHandler.execute(api, request).content.items.map { - BaseItem.from( - it, - api, + private fun getSeasons( + series: BaseItem, + seasonNum: Int?, + ): Deferred> = + viewModelScope.async(Dispatchers.IO) { + val request = + GetItemsRequest( + parentId = series.id, + recursive = false, + includeItemTypes = listOf(BaseItemKind.SEASON), + sortBy = listOf(ItemSortBy.INDEX_NUMBER), + sortOrder = listOf(SortOrder.ASCENDING), + fields = + if (seriesPageType == SeriesPageType.DETAILS) { + listOf( + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ) + } else { + null + }, ) - } - Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") - return seasons - } + val pager = + ApiRequestPager( + 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( seasonId: UUID, @@ -233,14 +296,11 @@ class SeriesViewModel ), ) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) - pager.init() + pager.init(episodeNumber ?: 0) val initialIndex = if (episodeId != null || episodeNumber != null) { - pager - .indexOfBlocking { - equalsNotNull(it?.id, episodeId) || - equalsNotNull(it?.indexNumber, episodeNumber) - }.coerceAtLeast(0) + findIndexOf(episodeNumber, episodeId, pager) + .coerceAtLeast(0) } else { // Force the first page to to be fetched if (pager.isNotEmpty()) { @@ -272,7 +332,7 @@ class SeriesViewModel if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { (episodes as? EpisodeList.Success) ?.let { - it.episodes.getOrNull(it.initialIndex) + it.episodes.getOrNull(it.initialEpisodeIndex) }?.let { lookupPeopleInEpisode(it) } } } @@ -312,7 +372,7 @@ class SeriesViewModel ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { setWatched(seasonId, played, null) val series = fetchItem(seriesId) - val seasons = getSeasons(series) + val seasons = getSeasons(series, null).await() this@SeriesViewModel.seasons.setValueOnMain(seasons) } @@ -320,7 +380,7 @@ class SeriesViewModel viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(seriesId, played) val series = fetchItem(seriesId) - val seasons = getSeasons(series) + val seasons = getSeasons(series, null).await() this@SeriesViewModel.seasons.setValueOnMain(seasons) } @@ -469,7 +529,7 @@ sealed interface EpisodeList { data class Success( val seasonId: UUID, val episodes: ApiRequestPager, - val initialIndex: Int, + val initialEpisodeIndex: Int, ) : EpisodeList } @@ -477,3 +537,49 @@ data class PeopleInItem( val itemId: UUID? = null, val people: List = 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 +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 038f231a..3ebd1cb1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -74,10 +74,10 @@ fun DestinationContent( is Destination.SeriesOverview -> { SeriesOverview( - preferences, - destination, - modifier, + preferences = preferences, + destination = destination, initialSeasonEpisode = destination.seasonEpisode, + modifier = modifier, ) }