From f8efffa3c2f6669454c03dd889e57f4f2a481ee2 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 3 Oct 2025 18:25:00 -0400 Subject: [PATCH] Handle mapping missing seasons or episodes --- .../dolphin/ui/detail/SeriesDetails.kt | 4 +- .../dolphin/ui/detail/SeriesViewModel.kt | 73 +++++++++++++------ .../ui/detail/series/SeriesOverview.kt | 36 +++++---- .../ui/detail/series/SeriesOverviewContent.kt | 13 +++- .../dolphin/ui/nav/DestinationContent.kt | 3 +- 5 files changed, 84 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt index 4e65299d..1492d375 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt @@ -29,7 +29,7 @@ fun SeriesDetails( viewModel.init(destination.itemId, destination.item, null, null) } val item by viewModel.item.observeAsState() - val seasons by viewModel.seasons.observeAsState(listOf()) + val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty()) val loading by viewModel.loading.observeAsState(LoadingState.Loading) when (val state = loading) { @@ -44,7 +44,7 @@ fun SeriesDetails( item { ItemRow( title = "Seasons", - items = seasons, + items = seasons.items, onClickItem = { navigationManager.navigateTo(it.destination()) }, onLongClickItem = { }, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt index 5b00b19b..08bbf0f0 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt @@ -52,8 +52,8 @@ class SeriesViewModel private var player: Player? = null private lateinit var seriesId: UUID val loading = MutableLiveData(LoadingState.Loading) - val seasons = MutableLiveData>(listOf()) - val episodes = MutableLiveData>(listOf()) + val seasons = MutableLiveData(ItemListAndMapping.empty()) + val episodes = MutableLiveData(ItemListAndMapping.empty()) fun init( itemId: UUID, @@ -70,22 +70,22 @@ class SeriesViewModel ) { val item = fetchItem(seriesId, potential) if (item != null) { - val seasonPager = getSeasons(item) - val episodePager = - season?.let { seasonNum -> - // TODO map season number to index in list - loadEpisodesInternal(seasonNum) - } + val seasonsInfo = getSeasons(item) + val episodeInfo = + (season ?: seasonsInfo.items.firstOrNull()?.indexNumber) + ?.let { seasonNum -> + loadEpisodesInternal(seasonNum) + } ?: ItemListAndMapping.empty() withContext(Dispatchers.Main) { - seasons.value = seasonPager.orEmpty() - episodes.value = episodePager.orEmpty() + seasons.value = seasonsInfo + episodes.value = episodeInfo loading.value = LoadingState.Success } maybePlayThemeSong() } else { withContext(Dispatchers.Main) { - seasons.value = listOf() - episodes.value = listOf() + seasons.value = ItemListAndMapping.empty() + episodes.value = ItemListAndMapping.empty() loading.value = LoadingState.Error("Series $seriesId not found") } } @@ -135,7 +135,7 @@ class SeriesViewModel player = null } - private suspend fun getSeasons(item: BaseItem): ApiRequestPager? { + private suspend fun getSeasons(item: BaseItem): ItemListAndMapping { val request = GetItemsRequest( parentId = item.id, @@ -164,12 +164,12 @@ class SeriesViewModel val season = pager.getBlocking(index) Pair(season?.indexNumber!!, index) } - mapOf(*pairs.toTypedArray()) - mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray()) - return pager + val seasonNumToIndex = mapOf(*pairs.toTypedArray()) + val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray()) + return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum) } - private suspend fun loadEpisodesInternal(season: Int): ApiRequestPager { + private suspend fun loadEpisodesInternal(season: Int): ItemListAndMapping { val request = GetEpisodesRequest( seriesId = item.value!!.id, @@ -188,21 +188,21 @@ class SeriesViewModel val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) pager.init() Timber.Forest.v("Loaded ${pager.size} episodes for season $season") - return pager + return convertPager(pager) } fun loadEpisodes(season: Int) = viewModelScope.async(ExceptionHandler(true)) { - val episodePager = + val episodes = try { loadEpisodesInternal(season) } catch (e: Exception) { Timber.Forest.e(e, "Error loading episodes for $seriesId for season $season") // TODO show error in UI? - listOf() + ItemListAndMapping.empty() } withContext(Dispatchers.Main) { - episodes.value = episodePager + this@SeriesViewModel.episodes.value = episodes } } @@ -225,9 +225,34 @@ class SeriesViewModel ) = viewModelScope.launch(ExceptionHandler()) { val base = api.userLibraryApi.getItem(itemId).content val item = BaseItem.Companion.from(base, api) + val eps = episodes.value!! episodes.value = - episodes.value!!.toMutableList().apply { - this[listIndex] = item - } + eps.copy( + items = + eps.items.toMutableList().apply { + this[listIndex] = item + }, + ) } } + +data class ItemListAndMapping( + val items: List, + val numberToIndex: Map, + val indexToNumber: Map, +) { + companion object { + fun empty() = ItemListAndMapping(listOf(), mapOf(), mapOf()) + } +} + +private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping { + val pairs = + pager.mapIndexed { index, _ -> + val season = pager.getBlocking(index) + Pair(season?.indexNumber!!, index) + } + val seasonNumToIndex = mapOf(*pairs.toTypedArray()) + val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray()) + return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum) +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt index 73da5f48..453e54e0 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt @@ -18,6 +18,7 @@ import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping import com.github.damontecres.dolphin.ui.detail.SeriesViewModel import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager @@ -48,7 +49,7 @@ fun SeriesOverview( destination: Destination.MediaItem, modifier: Modifier = Modifier, viewModel: SeriesViewModel = hiltViewModel(), - initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0), + initialSeasonEpisode: SeasonEpisode? = null, ) { val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } @@ -58,19 +59,20 @@ fun SeriesOverview( viewModel.init( destination.itemId, destination.item, - initialSeasonEpisode.season, - initialSeasonEpisode.episode, + initialSeasonEpisode?.season, + initialSeasonEpisode?.episode, ) } val loading by viewModel.loading.observeAsState(LoadingState.Loading) val series by viewModel.item.observeAsState(null) - val seasons by viewModel.seasons.observeAsState(listOf()) - val episodes by viewModel.episodes.observeAsState(listOf()) + val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty()) + val episodes by viewModel.episodes.observeAsState(ItemListAndMapping.empty()) // TODO need to translate season/episode into tab/row index in case of missing seasons/episodes var position by rememberSaveable( destination, + loading, stateSaver = Saver( save = { listOf(it.seasonTabIndex, it.episodeRowIndex) }, @@ -79,19 +81,19 @@ fun SeriesOverview( ) { mutableStateOf( SeriesOverviewPosition( - initialSeasonEpisode.season, - initialSeasonEpisode.episode, + seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0, + episodes.numberToIndex[initialSeasonEpisode?.episode ?: 0] ?: 0, ), ) } var overviewDialog by remember { mutableStateOf(null) } - LaunchedEffect(episodes) { - if (episodes.isNotEmpty()) { + LaunchedEffect(episodes.items) { + if (episodes.items.isNotEmpty()) { // TODO focus on first episode when changing seasons // firstItemFocusRequester.requestFocus() - episodes.getOrNull(position.episodeRowIndex)?.let { + episodes.items.getOrNull(position.episodeRowIndex)?.let { viewModel.refreshEpisode(it.id, position.episodeRowIndex) } } @@ -107,8 +109,8 @@ fun SeriesOverview( LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } SeriesOverviewContent( series = series, - seasons = seasons, - episodes = episodes, + seasons = seasons.items, + episodes = episodes.items, position = position, backdropImageUrl = remember { @@ -121,7 +123,9 @@ fun SeriesOverview( episodeRowFocusRequester = episodeRowFocusRequester, onFocus = { if (it.seasonTabIndex != position.seasonTabIndex) { - viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!) + seasons.indexToNumber[it.seasonTabIndex]?.let { seasonNumber -> + viewModel.loadEpisodes(seasonNumber) + } } position = it }, @@ -143,7 +147,7 @@ fun SeriesOverview( // TODO }, playOnClick = { resume -> - episodes.getOrNull(position.episodeRowIndex)?.let { + episodes.items.getOrNull(position.episodeRowIndex)?.let { viewModel.release() navigationManager.navigateTo( Destination.Playback( @@ -155,7 +159,7 @@ fun SeriesOverview( } }, watchOnClick = { - episodes.getOrNull(position.episodeRowIndex)?.let { + episodes.items.getOrNull(position.episodeRowIndex)?.let { val played = it.data.userData?.played ?: false viewModel.setWatched(it.id, !played, position.episodeRowIndex) } @@ -164,7 +168,7 @@ fun SeriesOverview( // TODO show more actions }, overviewOnClick = { - episodes.getOrNull(position.episodeRowIndex)?.let { + episodes.items.getOrNull(position.episodeRowIndex)?.let { overviewDialog = ItemDetailsDialogInfo( title = it.name ?: "Unknown", diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt index b459fc70..b4940da2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester 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 @@ -40,9 +41,11 @@ import androidx.tv.material3.TabRow import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.dolphin.data.model.BaseItem +import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect import com.github.damontecres.dolphin.ui.cards.BannerCard import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.isNotNullOrBlank +import com.github.damontecres.dolphin.ui.tryRequestFocus import kotlin.time.Duration @Composable @@ -71,6 +74,8 @@ fun SeriesOverviewContent( val tabRowFocusRequester = remember { FocusRequester() } val focusedEpisode = episodes.getOrNull(position.episodeRowIndex) + LaunchedEffect(position) { + } Box( modifier = @@ -174,6 +179,12 @@ fun SeriesOverviewContent( item { key(position.seasonTabIndex) { val state = rememberLazyListState() + OneTimeLaunchedEffect { + if (state.firstVisibleItemIndex != position.episodeRowIndex) { + state.scrollToItem(position.episodeRowIndex) + firstItemFocusRequester.tryRequestFocus() + } + } LazyRow( state = state, horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -205,7 +216,7 @@ fun SeriesOverviewContent( onLongClick = { if (episode != null) onLongClick.invoke(episode) }, modifier = Modifier.ifElse( - episodeIndex == 0, + episodeIndex == position.episodeRowIndex, Modifier.focusRequester(firstItemFocusRequester), ), interactionSource = interactionSource, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index 9492ffbd..a240a2c3 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -9,7 +9,6 @@ import com.github.damontecres.dolphin.ui.detail.EpisodeDetails import com.github.damontecres.dolphin.ui.detail.SeasonDetails import com.github.damontecres.dolphin.ui.detail.VideoDetails import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails -import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview import com.github.damontecres.dolphin.ui.main.MainPage import com.github.damontecres.dolphin.ui.playback.PlaybackContent @@ -64,7 +63,7 @@ fun DestinationContent( navigationManager, destination, modifier, - initialSeasonEpisode = destination.seasonEpisode ?: SeasonEpisode(0, 0), + initialSeasonEpisode = destination.seasonEpisode, ) BaseItemKind.SEASON ->