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

View file

@ -88,13 +88,15 @@ fun SeriesDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(),
viewModel: SeriesViewModel =
hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(
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<BaseItem>,
seasons: List<BaseItem?>,
similar: List<BaseItem>,
trailers: List<Trailer>,
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.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<SeriesViewModel, SeriesViewModel.Factory>(
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) {
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

View file

@ -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,7 +170,7 @@ fun SeriesOverviewContent(
modifier = Modifier.fillMaxWidth(.6f),
)
key(position.seasonTabIndex) {
// key(position.seasonTabIndex) {
when (val eps = episodes) {
EpisodeList.Loading -> {
LoadingPage()
@ -202,12 +203,7 @@ fun SeriesOverviewContent(
itemsIndexed(eps.episodes) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() }
if (interactionSource.collectIsFocusedAsState().value) {
onFocus.invoke(
SeriesOverviewPosition(
selectedTabIndex,
episodeIndex,
),
)
onFocusEpisode.invoke(episodeIndex)
}
val cornerText =
episode?.data?.indexNumber?.let { "E$it" }
@ -264,7 +260,7 @@ fun SeriesOverviewContent(
}
}
}
}
// }
}
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.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>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem>>(listOf())
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val trailers = MutableLiveData<List<Trailer>>(listOf())
@ -90,48 +110,77 @@ class SeriesViewModel
val peopleInEpisode = MutableLiveData<PeopleInItem>(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 =
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
val episodeListDeferred =
if (seriesPageType == SeriesPageType.OVERVIEW) {
viewModelScope.async(Dispatchers.IO) {
if (seasonEpisodeIds != null) {
seasons.firstOrNull {
equalsNotNull(it.id, seasonEpisodeIds.seasonId) ||
equalsNotNull(it.indexNumber, seasonEpisodeIds.seasonNumber)
}
loadEpisodesInternal(
seasonEpisodeIds.seasonId,
seasonEpisodeIds.episodeId,
seasonEpisodeIds.episodeNumber,
)
} else {
seasons.firstOrNull()
}
val episodeInfo =
initialSeason?.let {
seasonsDeferred.await().firstOrNull()?.let {
loadEpisodesInternal(
it.id,
seasonEpisodeIds?.episodeId,
seasonEpisodeIds?.episodeNumber,
null,
null,
)
} ?: EpisodeList.Error("Could not determine season for selected episode")
} ?: EpisodeList.Error(message = "Could not determine season")
}
}
} else {
CompletableDeferred(value = EpisodeList.Loading)
}
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,7 +234,11 @@ class SeriesViewModel
themeSongPlayer.stop()
}
private suspend fun getSeasons(series: BaseItem): List<BaseItem> {
private fun getSeasons(
series: BaseItem,
seasonNum: Int?,
): Deferred<List<BaseItem?>> =
viewModelScope.async(Dispatchers.IO) {
val request =
GetItemsRequest(
parentId = series.id,
@ -194,21 +247,31 @@ class SeriesViewModel
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING),
fields =
if (seriesPageType == SeriesPageType.DETAILS) {
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,
} else {
null
},
)
val pager =
ApiRequestPager(
api,
)
}
Timber.v("Loaded ${seasons.size} seasons for series ${series.id}")
return seasons
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(
@ -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<GetEpisodesRequest>,
val initialIndex: Int,
val initialEpisodeIndex: Int,
) : EpisodeList
}
@ -477,3 +537,49 @@ data class PeopleInItem(
val itemId: UUID? = null,
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 -> {
SeriesOverview(
preferences,
destination,
modifier,
preferences = preferences,
destination = destination,
initialSeasonEpisode = destination.seasonEpisode,
modifier = modifier,
)
}