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:
Ray 2025-12-24 16:47:17 -05:00 committed by GitHub
parent b671e14826
commit 2ecc3f262d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 297 additions and 214 deletions

View file

@ -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) }

View file

@ -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>,

View file

@ -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

View file

@ -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 ->

View file

@ -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
}

View file

@ -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,
) )
} }